From 9df7944381d1974998a7fc4005fb10fedc7c3ab1 Mon Sep 17 00:00:00 2001 From: heaven Date: Fri, 14 Aug 2026 14:17:42 +0300 Subject: [PATCH] Freeze the phase-D harness before the en-axis purchases: pair provider, wired rig hooks, pinned unit manifests, pair terminologist prompts and the paid contamination path --- eval/dovodka/contam_d.py | 102 +++++- eval/dovodka/contour.py | 78 ++++- eval/dovodka/itog_d4.py | 9 +- eval/dovodka/material_ja.py | 74 +++- eval/dovodka/para.py | 320 ++++++++++++++++++ eval/dovodka/plan.py | 7 +- eval/dovodka/sud.py | 29 +- eval/role_topology/battery.py | 54 ++- eval/role_topology/pair_en.py | 54 ++- .../prompts/en-ru/terminologist.md | 86 +++++ .../prompts/ja-ru/terminologist.md | 89 +++++ eval/tenant_panel/bank.py | 106 +++++- eval/tenant_panel/prompts.py | 52 ++- 13 files changed, 993 insertions(+), 67 deletions(-) create mode 100644 eval/dovodka/para.py create mode 100644 eval/role_topology/prompts/en-ru/terminologist.md create mode 100644 eval/role_topology/prompts/ja-ru/terminologist.md diff --git a/eval/dovodka/contam_d.py b/eval/dovodka/contam_d.py index 07cd11f8..643bca76 100644 --- a/eval/dovodka/contam_d.py +++ b/eval/dovodka/contam_d.py @@ -55,6 +55,21 @@ def _load(name: str, path: Path): MONEY = _load("money_d", ZONE / "money_d.py") CT = _load("contam21", REPO / "eval" / "role_topology" / "contamination.py") +ROSTER = _load("roster_c", REPO / "eval" / "tenant_panel" / "roster.py") +ED = _load("editors_c", REPO / "eval" / "tenant_panel" / "editors.py") + +# ⚠ БЮДЖЕТ ВЫВОДА. У DeepSeek размышление тарифицируется и считается ВНУТРИ бюджета, поэтому +# короткий ответ при тесном капе не помещается вовсе: прошлая редакция звала flash с 16000 и +# получала 0–46 знаков. Ответ здесь нужен в две строки, думать модель будет много — кап должен +# вмещать размышление ЦЕЛИКОМ, иначе пустота будет означать «не успел сказать», а не «не знаю». +MAX_OUT = 32000 +# Глушить thinking у DeepSeek ЗАПРЕЩЕНО (эхо-мина, гардрейл проекта). Конфигурацию на модель +# даёт `roster.call_kwargs` — своих `reasoning_effort` здесь не ставим НИКОГДА. +SYS = ("Тебе дают отрывок художественного текста, в котором одно имя собственное заменено на " + "[???]. Ответь РОВНО двумя строками и ничем больше:\n" + "ПРОИЗВЕДЕНИЕ: <название и автор, либо НЕ ЗНАЮ>\n" + "ИМЯ: <что стоит вместо [???], либо НЕ ЗНАЮ>\n" + "Не пересказывай отрывок и не продолжай его.") # Жильцы: те, кто реально работает на парах. Проба на каждом отдельно. RESIDENTS = ("deepseek-v4-flash", "deepseek-v4-pro") @@ -75,6 +90,43 @@ BOOKS_D = { } +# Книга → фаза кассы. Положительный контроль относим к ФД-B: он покупается один раз, а прогон +# Д6 возьмёт те же теги из кэша за $0. +PHASE = {"kristoff-en": "ФД-B", "jinpingmei": "ФД-B", "enkan-ja": "ФД-C"} +PREFIX = {"ФД-B": "dv-b", "ФД-C": "dv-c"} + + +def probe(res: str, book: str, dry: bool) -> dict: + """Проба одного жильца на одной книге. Возвращает счёт узнавания и клоуза.""" + m = BOOKS_D[book] + text = CT.read_book(m["path"]) + names = CT.frequent_names(text, m["lang"]) + ps = CT.passages(text, names, CT.N_PASSAGES) + ph = PHASE[book] + led = MONEY.Guarded(ph, default_expect=0.01) + rec_hits = cloze_hits = empty = 0 + for i, (prime, gold) in enumerate(ps, 1): + tg = f"{PREFIX[ph]}-contam-{res.replace('.', '')}-{book}-{i}" + if dry: + continue + msgs = [{"role": "system", "content": SYS}, {"role": "user", "content": prime}] + r = MONEY.purchase(led, tg, res, ED.client(res), + ROSTER.call_kwargs(res, msgs, max_out=MAX_OUT)) + if r.get("skipped"): + raise SystemExit(f"⛔ ГАРД ОТКАЗАЛ на {tg} — СТОП и вопрос владельцу") + ans = r.get("content") or "" + if not ans.strip(): + empty += 1 + continue + work, name = CT.parse(ans) + if work and "НЕ ЗНАЮ" not in work.upper() and any(t.lower() in work.lower() + for t in m["titles"]): + rec_hits += 1 + if name and "НЕ ЗНАЮ" not in name.upper() and gold and gold.lower() in name.lower(): + cloze_hits += 1 + return dict(recognition=rec_hits, cloze=cloze_hits, empty=empty, n=len(ps)) + + def main() -> int: dry = "--dry" in sys.argv print("КОНТАМИНАЦИОННАЯ ПРОБА ФАЗЫ Д — жильцами\n") @@ -85,11 +137,55 @@ def main() -> int: print(f"\nжильцы: {' · '.join(RESIDENTS)}") print("пороги (объявлены ДО данных, взяты из рига эксп-21): " "узнавание ≥3/5 = знает о книге · клоуз ≥2/5 = знает конкретику") + only = [a.split("=", 1)[1] for a in sys.argv if a.startswith("--books=")] + books = only[0].split(",") if only else list(BOOKS_D) + print(f"\nкниги прогона: {' · '.join(books)}") if dry: - print("\n--dry: покупок нет") + print("--dry: покупок нет; каждая книга = 5 отрывков × 2 жильца") return 0 - print("\n⚠ платный режим ещё не подключён к кассе фазы — см. TODO ниже") - return 0 + + res_tbl: dict[tuple[str, str], dict] = {} + for res in RESIDENTS: + for b in books: + res_tbl[(res, b)] = probe(res, b, dry=False) + + print(f"\n{'жилец':20s}{'книга':14s}{'узнавание':>11s}{'клоуз':>8s}{'пусто':>7s} вердикт") + bad = 0 + for (res, b), v in res_tbl.items(): + known = v["recognition"] >= 3 + concrete = v["cloze"] >= 2 + verdict = ("ЗНАЕТ ТЕКСТ" if concrete else "знает о книге" if known else "чисто") + print(f" {res:18s}{b:14s}{v['recognition']:>7d}/{v['n']}{v['cloze']:>6d}/{v['n']}" + f"{v['empty']:>7d} {verdict}") + if b != "jinpingmei" and concrete: + bad += 1 + + print("\n=== ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ (без него нулевой результат неинтерпретируем) ===") + for res in RESIDENTS: + c = res_tbl.get((res, "jinpingmei")) + if not c: + print(f" {res}: контроль не гнался — прогон этого жильца НЕИНТЕРПРЕТИРУЕМ") + bad += 1 + continue + ok = c["recognition"] >= 3 + # ⚠ Пустота при поднятом капе — НЕ «книга чиста», а непригодность пробы на этом жильце. + if c["empty"] == c["n"]: + print(f" {res}: ПУСТЫЕ ОТВЕТЫ на контроле — проба на этом жильце НЕПРИГОДНА, " + "нули по остальным книгам не читать") + bad += 1 + elif not ok: + print(f" {res}: контроль ПРОВАЛЕН ({c['recognition']}/{c['n']}) — прогон жильца " + "аннулируется целиком, а не отдельная книга") + bad += 1 + else: + print(f" {res}: контроль сработал ({c['recognition']}/{c['n']}) — нули значимы") + + if bad: + print("\n⛔ проба даёт СТОП: см. строки выше. Замену материала без слова владельца " + "не ищем (заказ Д3/Д6).") + else: + print("\n[OK ] материал фазы пригоден: жильцы конкретики книг не знают") + return 1 if bad else 0 if __name__ == "__main__": diff --git a/eval/dovodka/contour.py b/eval/dovodka/contour.py index 0bfa88af..8aefe243 100644 --- a/eval/dovodka/contour.py +++ b/eval/dovodka/contour.py @@ -50,6 +50,8 @@ PR = _load("prompts22", REPO / "eval" / "tenant_panel" / "prompts.py") BANK = _load("bank22", REPO / "eval" / "tenant_panel" / "bank.py") ROSTER = _load("roster22", REPO / "eval" / "tenant_panel" / "roster.py") ED22 = _load("editors22", REPO / "eval" / "tenant_panel" / "editors.py") +PARA = _load("para", ZONE / "para.py") +B = PARA.B # тот же экземпляр рига, что видит провайдер пары # ⚠⚠ КАССУ ПЕРЕ-НАСТРАИВАЕМ ПОСЛЕ ВСЕХ ЧУЖИХ ЗАГРУЗОК, И ЭТО НЕ ПЕДАНТИЗМ. # Модуль `buy` — СИНГЛТОН в `sys.modules`, и побеждает тот, кто настроил его последним. `money_d` @@ -95,9 +97,44 @@ EXCLUDED = { } +# ── ПАРА КАК ПАРАМЕТР ───────────────────────────────────────────────────────────────────────── +# ⚠ Заведено 14.08 по вычитке. Хуки в риге были построены, но НЕ ПОДКЛЮЧЕНЫ ни в одной точке +# входа: греп по `eval/` не находил ни одного вызова `battery.configure_pair`/`bank.configure`. +# Забытая проводка даёт не падение, а китайский разбор величин и палладий-линт на английском +# тексте — целевой класс «тихо неверное число». Здесь единственное место, где пара включается, +# и включается ЦЕЛИКОМ: риг, банк, бриф, письменность, теги и источник единиц разом. +_PAIR = None +_UNITS = None + + +def pair(): + global _PAIR + if _PAIR is None: + _PAIR = PARA.ZH + return _PAIR + + +def configure(p, units_provider=None) -> None: + """Перевести контур на пару `p`. Для zh — проверенный no-op (селфтест это стережёт).""" + global _PAIR, _UNITS + _PAIR, _UNITS = p, units_provider + B.configure_pair(SOURCE_VALUES_HOOK=p.values_in_source, + NAME_CONFORMANT_TEXT_HOOK=p.name_conformant_text, + NAME_CONFORMANT_BANK_HOOK=p.name_conformant, + SOURCE_SCRIPT_HOOK=p.source_script) + BANK.configure(p, material=lambda: "\n".join(u["source"] for u in units())) + + +def _bank() -> dict: + P = pair() + return BANK.PR.bank(None if P.key == "zh-ru" else P.bank_file) + + # ── единицы: 16 эксп-22 + 16 эксп-23, база у каждой своя и уже куплена ──────────────────────── def units() -> list[dict]: """ВСЕ единицы среза, включая исключённые, — для учёта и кассы.""" + if _UNITS is not None: + return _UNITS() return P22.M.units_zh() + MAT.units_new() @@ -148,7 +185,7 @@ def canon_gaps(src: str, out: str) -> list[str]: """Термины банка, чья канонная форма В ВЫХОДЕ встречается реже, чем в исходнике.""" import re # noqa: PLC0415 gaps = [] - for t, v in BANK.PR.bank().items(): + for t, v in _bank().items(): n = src.count(t) if not n: continue @@ -205,7 +242,7 @@ def battery_flags(u: dict, draft: str) -> list[str]: """Нарушения батареи НА ТЕКСТЕ, каждое с адресом. Падение = дефект, НЕ глотаем.""" import battery as B # noqa: PLC0415 import re # noqa: PLC0415 - bank = BANK.PR.bank() + bank = _bank() unit = B.Unit(source=u["source"], draft=draft, final=draft, bank_dst={v["dst"] for v in bank.values()}, term_pairs=[(t, v["dst"]) for t, v in bank.items() if u["source"].count(t)]) @@ -222,7 +259,7 @@ def battery_flags(u: dict, draft: str) -> list[str]: if addr_field: addr = val.get(addr_field) or [] elif check == "cjk_leak": - addr = sorted(set(re.findall(r"[一-鿿]", draft)))[:12] + addr = sorted(set(pair().source_script.findall(draft)))[:12] else: addr = [] if addr_field and not addr: # признак есть, адреса нет — не место @@ -259,7 +296,11 @@ CRIT_HEAD = ( def fix_msgs(src: str, draft: str, places: list[str]) -> list[dict]: - law = PR.law_block(PR.terms_in(src)) + # ⚠ `law_block` возвращает None, когда термов банка в единице нет, — интерполяция уносила в + # системный промт литерал «None». На 31 оплаченной китайской единице пустых не было, поэтому + # не выстрелило; на паре без банка выстрелило бы на КАЖДОЙ. + bf = None if pair().key == "zh-ru" else pair().bank_file + law = PR.law_block(PR.terms_in(src, bf), bf) or "" body = (f"{FIX_HEAD}\n\n{law}\n\n---USER---\nИСХОДНИК:\n{src}\n\nЧЕРНОВОЙ ПЕРЕВОД:\n{draft}\n\n" "МЕСТА, КОТОРЫЕ НАДО ПОЧИНИТЬ:\n" + "\n".join(f"- {p}" for p in places)) s, u = body.split("---USER---", 1) @@ -269,7 +310,11 @@ def fix_msgs(src: str, draft: str, places: list[str]) -> list[dict]: def crit_msgs(src: str, draft: str) -> list[dict]: # Критику банк-закон нужен не меньше фиксера: без него он назовёт «непоследовательным» то, # что канону как раз отвечает, и наоборот. Банк-закон D39.104 — на ВСЕХ армах, без изъятий. - law = PR.law_block(PR.terms_in(src)) + # ⚠ `law_block` возвращает None, когда термов банка в единице нет, — интерполяция уносила в + # системный промт литерал «None». На 31 оплаченной китайской единице пустых не было, поэтому + # не выстрелило; на паре без банка выстрелило бы на КАЖДОЙ. + bf = None if pair().key == "zh-ru" else pair().bank_file + law = PR.law_block(PR.terms_in(src, bf), bf) or "" return [{"role": "system", "content": f"{CRIT_HEAD}\n\n{law}"}, {"role": "user", "content": f"ИСХОДНИК:\n{src}\n\nЧЕРНОВОЙ ПЕРЕВОД:\n{draft}"}] @@ -308,7 +353,7 @@ A2_SUBST = ( def a2_mandate() -> str: """Мандатная часть боевого редактора, переписанная под однопроходку. $0, детерминирована.""" core, _few, _user = PR.load_template(PR.BATTLE / "editor.md") - core = PR.render(core, PR.BRIEF_ZH, "", "") + core = PR.render(core, getattr(PR, pair().brief_key), "", "") out = [] for ln in core.splitlines(): if any(ln.strip().startswith(d) for d in A2_DROP): @@ -321,14 +366,21 @@ def a2_mandate() -> str: def a2_msgs(src: str) -> list[dict]: """Однопроходка: перевод исходника ОДНИМ вызовом, мандат уравнен со связкой, банк-закон на месте.""" - m = PR.translator_msgs(src, PR.law_block(PR.terms_in(src))) + bf = None if pair().key == "zh-ru" else pair().bank_file + m = PR.translator_msgs(src, PR.law_block(PR.terms_in(src, bf), bf), + pair=pair().key) m[0]["content"] = m[0]["content"] + "\n\n" + a2_mandate() return m def tag(arm: str, uid: str, part: str = "") -> str: + # ⚠ Тег несёт пару: без неё кэш `purchase` отдал бы оплаченную КИТАЙСКУЮ клетку под видом + # английской, за $0 и без предупреждения. У zh префикс пустой — теги уже купленных клеток + # обязаны остаться прежними. + P = pair() + pk = "" if P.key == "zh-ru" else P.key.split("-")[0] + "-" ph = "f" if arm.startswith("A6") else "a" - return f"dv-{ph}-{arm.lower()}{('-' + part) if part else ''}-{uid}" + return f"dv-{pk}{ph}-{arm.lower()}{('-' + part) if part else ''}-{uid}" def text_of(arm: str, uid: str) -> str: @@ -450,6 +502,16 @@ def cmd_selftest() -> int: f"кап {PLACES_CAP}, максимум найденного " f"{max((critic_places('A3', x['uid'])[1] for x in us), default=0)}") # ⚠ Главный ассерт этого харнесса: кэш-чек и ЗАПИСЬ результата смотрят в ОДИН каталог. + _b0 = [(len(battery_flags(u, base_draft(u["uid"]))), len(canon_gaps(u["source"], base_draft(u["uid"])))) + for u in units_ok() if base_draft(u["uid"]).strip()] + _t0 = tag("A1B", "zzz") + configure(PARA.ZH) + _b1 = [(len(battery_flags(u, base_draft(u["uid"]))), len(canon_gaps(u["source"], base_draft(u["uid"])))) + for u in units_ok() if base_draft(u["uid"]).strip()] + ck("проводка пары zh — no-op: числа и теги закрытой оси не двигаются", + _b0 == _b1 and _t0 == tag("A1B", "zzz"), + f"было {sum(x for x, _ in _b0)}/{sum(y for _, y in _b0)}, " + f"стало {sum(x for x, _ in _b1)}/{sum(y for _, y in _b1)}") ck("buy.OUT совпадает с кассой фазы", MONEY.M22.BUY.OUT == MONEY.OUT, f"{MONEY.M22.BUY.OUT} против {MONEY.OUT}") print(f"\n{'ХАРНЕСС ГОДЕН' if not bad else f'ПРОВАЛОВ: {bad}'}") diff --git a/eval/dovodka/itog_d4.py b/eval/dovodka/itog_d4.py index 185e0cf1..d297c81c 100644 --- a/eval/dovodka/itog_d4.py +++ b/eval/dovodka/itog_d4.py @@ -103,7 +103,10 @@ def read_family(root: Path) -> tuple[dict, list]: for lab, meta in key.items(): sc = {} for a in AX: - m = re.search(rf"^{lab}-{a}:\s*(\d+)", txt, re.M) + # ⚠ `[ \t]*`, а не `\s*`: `\s` включает перевод строки, и оценка + # могла считаться со СЛЕДУЮЩЕЙ строки. Тот же класс `absjudge.py:351` + # чинил у себя; здесь он оставался. + m = re.search(rf"^{lab}-{a}:[ \t]*(\d+)", txt, re.M) if m: sc[a] = int(m.group(1)) if len(sc) < 4: @@ -201,8 +204,10 @@ def three_axes(d: dict) -> None: if n: cov.append(k / n) pr = [] + # ⚠ Глоб по тегу арма захватывал и клетки КРИТИКА (`dv-a-a3-crit-*`), поэтому + # медиана «$/клетка» у A3 и A6 считалась по смеси двух ролей. Найдено приёмкой. for f in S.C.OUT.glob(f"{S.C.tag(arm, '')}*.json"): - if any(x in f.name for x in (".ERROR", ".LENGTH", ".FLAGSV1")): + if any(x in f.name for x in (".ERROR", ".LENGTH", ".FLAGSV1", "-crit-")): continue r = _j.loads(f.read_text(encoding="utf-8")) if r.get("cost_usd"): diff --git a/eval/dovodka/material_ja.py b/eval/dovodka/material_ja.py index 532d2af0..23a7dd20 100644 --- a/eval/dovodka/material_ja.py +++ b/eval/dovodka/material_ja.py @@ -121,6 +121,56 @@ def guardrail(cs: list[dict]) -> list[int]: return hits +def select(cs: list[dict]) -> tuple[list[dict], set[int], list]: + """Гейт прав и отбор единиц. Вынесено из `main()`, чтобы отбор был ВЫЗЫВАЕМ, а не только + печатаем: покупка и судейство обязаны брать ровно те единицы, что напечатал отчёт.""" + dens = sorted(((len(EROTIC.findall(c["text"])) / max(1, len(c["text"])) * 1000, i) + for i, c in enumerate(cs)), reverse=True) + cut = max(1, len(cs) // 10) + dropped = {i for _, i in dens[:cut]} + pool = [c for i, c in enumerate(cs) if i not in dropped] + uniq: dict[str, dict] = {} + for c in pool: + u = uid_of(c["text"]) + uniq.setdefault(u, dict(uid=u, source=c["text"], chapter=c["chapter"])) + ordered = sorted(uniq.values(), key=lambda x: (len(x["source"]), x["uid"])) + mid = len(ordered) // 2 + lo = max(0, mid - N_UNITS // 2) + return ordered[lo:lo + N_UNITS], dropped, dens + + +def units() -> list[dict]: + """Единицы среза Д6 для покупки и судейства — ТОЛЬКО из приколотого манифеста. + + ⚠ Сама не решает и не «выбирает по-новой». Гардрейл 18+ разрешается словом владельца, и + решение это разовое: пока манифеста нет, отбор не состоялся, и молча подставить набор + нельзя — иначе библиотечный вызов обойдёт гардрейл, который CLI честно охраняет. + Расхождение приколотого uid с пересчитанным = источник сдвинулся под уже купленными + клетками; это СТОП, а не предупреждение (класс, пойманный докачкой глав на zh-срезе). + """ + if not MANIFEST.exists(): + raise SystemExit(f"⛔ манифест {MANIFEST} не записан: отбор ja не заморожен. " + "Прогнать material_ja.py --units при слове владельца по гардрейлу.") + man = json.loads(MANIFEST.read_text(encoding="utf-8")) + want = man["ja"]["uids"] + # ⚠ ГАРДРЕЙЛ ПРИМЕНЯЕТСЯ И ЗДЕСЬ. `main()` при санкции владельца выбрасывает помеченные + # чанки ДО отбора, и манифест записан из отфильтрованного списка. Если бы `units()` считала + # по нефильтрованному, дециль и окно отбора сместились бы — приколотые uid не нашлись бы, и + # прибор дал бы вечный ложный СТОП. Сегодня это тождество (0 срабатываний из 48), но + # тождество СЛУЧАЙНОЕ, а не гарантированное. + cs = chunks() + bad = set(guardrail(cs)) + if bad and not man.get("sanctioned_exclude"): + raise SystemExit(f"⛔ гардрейл 18+ помечает {len(bad)} чанков, а санкция владельца в " + f"манифесте не записана — материал не идёт в отбор, СТОП") + have = {u["uid"]: u for u in select([c for i, c in enumerate(cs) if i not in bad])[0]} + miss = [u for u in want if u not in have] + if miss: + raise SystemExit(f"⛔ ЕДИНИЦЫ РАЗЪЕХАЛИСЬ С МАНИФЕСТОМ ja: нет {miss[:3]} — " + "источник изменился под купленными клетками, СТОП") + return [have[u] for u in want] + + def main() -> int: cs = chunks() print(f"Д6 МАТЕРИАЛ ja→ru · источник {SRC.name} · знаков {len(text())} · чанков {len(cs)}") @@ -151,36 +201,26 @@ def main() -> int: print(f"[OK ] срабатываний нет: 0 из {len(cs)} чанков") print("\n=== ГЕЙТ ПРАВ (верхний дециль эротической плотности снимается ДО отбора) ===") - dens = sorted(((len(EROTIC.findall(c["text"])) / max(1, len(c["text"])) * 1000, i) - for i, c in enumerate(cs)), reverse=True) + units_, dropped, dens = select(cs) cut = max(1, len(cs) // 10) - dropped = {i for _, i in dens[:cut]} print(f"снято чанков {len(dropped)} из {len(cs)} (дециль); плотность верхнего " f"{dens[0][0]:.2f}/1000 знаков, порога дециля {dens[cut - 1][0]:.2f}, медиана " f"{dens[len(dens) // 2][0]:.2f}") - pool = [c for i, c in enumerate(cs) if i not in dropped] print("\n=== ОТБОР ЕДИНИЦ (детерминированный, методика эксп-21 §0) ===") - uniq: dict[str, dict] = {} - for c in pool: - u = uid_of(c["text"]) - uniq.setdefault(u, dict(uid=u, source=c["text"], chapter=c["chapter"])) - ordered = sorted(uniq.values(), key=lambda x: (len(x["source"]), x["uid"])) - mid = len(ordered) // 2 - lo = max(0, mid - N_UNITS // 2) - units = ordered[lo:lo + N_UNITS] - ls = [len(u["source"]) for u in units] - print(f"пул {len(ordered)} уникальных · выбрано {len(units)} из середины распределения длин") + ls = [len(u["source"]) for u in units_] + print(f"выбрано {len(units_)} из середины распределения длин") print(f"знаков {min(ls)}…{max(ls)}, медиана {sorted(ls)[len(ls) // 2]} · " - f"глав {len({u['chapter'] for u in units})}") - for u in units: + f"глав {len({u['chapter'] for u in units_})}") + for u in units_: print(f" {u['uid']} гл.{u['chapter']:<4d} знаков {len(u['source']):5d}") if "--units" in sys.argv: OUT.mkdir(parents=True, exist_ok=True) MANIFEST.write_text(json.dumps( dict(source=SRC.name, n_chunks=len(cs), dropped_rights=len(dropped), - ja=dict(uids=[u["uid"] for u in units])), ensure_ascii=False, indent=1), + sanctioned_exclude="--owner-sanctioned-exclude" in sys.argv, + ja=dict(uids=[u["uid"] for u in units_])), ensure_ascii=False, indent=1), encoding="utf-8") print(f"\nманифест → {MANIFEST} (единицы приколоты; докачка их больше не сдвинет)") else: diff --git a/eval/dovodka/para.py b/eval/dovodka/para.py new file mode 100644 index 00000000..a3d6720a --- /dev/null +++ b/eval/dovodka/para.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""ПАР-ПРОВАЙДЕР: всё, что харнесс знает о конкретной языковой ПАРЕ. $0. + +Канон владельца: движок ОДИН и общий, пара-специфика живёт в ДАННЫХ. Ревью-вопрос по умолчанию — +«заработает ли пара, которой в репо ещё НЕТ, без правки кода?». До этого файла ответ был «нет»: +пара сидела модульными глобалями в шести местах, и три из них давали не падение, а ТИХО НЕВЕРНОЕ +ЧИСЛО. Здесь пара становится объектом, который передаётся аргументом. + +⚠⚠ ГЛАВНАЯ НОРМА ЭТОГО ФАЙЛА, И ОНА НЕ КОСМЕТИЧЕСКАЯ. +**Пара, у которой класс детерминированного флага НЕ РЕАЛИЗОВАН, обязана СНЯТЬ его вслух.** +Молчаливый ноль мест недопустим, потому что арм `A1B` объявлен как «черновик + детерминированные +флаги», и его содержание ЕСТЬ список этих флагов. Замер на zh: 63% мест дал канон-гейт, 27% +разбор чисел, 10% палладий. Если на другой паре канон молча даёт ноль, а палладий молча даёт +ложь — `A1B` на двух парах суть РАЗНЫЕ АРМЫ под одним именем, и любой кросс-парный вывод +(цель №2 канона владельца) оказывается артефактом. Поэтому у каждой пары есть `absent` — +перечень снятых классов, который печатается в отчёте рядом с числами. + +⚠ ЧТО БЫЛО БЫ БЕЗ ЭТОГО ФАЙЛА (замерено на английском исходнике, не предположено): + * банк китайский ⇒ `terms_in(en_source)` = 0 ⇒ канон-гейт даёт 0 мест на КАЖДОЙ единице + ⇒ арм `A4` уходит в «мест нет» и его клетка становится ПОБАЙТНО равна `A0`. Судья получает + два одинаковых текста под разными метками, контраст ровно 0 по построению — тот самый + «близнец», из-за которого контроль пака 23 был пуст; + * `law_block(terms_in(en))` возвращает `None`, и в системный промт критика уезжает строковый + литерал `None` — проверено исполнением; + * разбор величин исходника знает только китайскую запись ⇒ потери чисел не ловятся ВОВСЕ, + а каждое русское числительное ≥100 в выходе объявляется выдумкой; + * палладий-линт — система транскрипции С КИТАЙСКОГО — применяется к французским именам + Кристоффа и флагует их как дефект, причём С ЦИТАТОЙ, то есть проходит гейт «флаг без адреса». + +Запуск: para.py сводка по пáрам + para.py --selftest обязателен до первой покупки новой пары +""" +from __future__ import annotations + +import importlib.util +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +REPO = Path("/home/ubuntu/projects/textmachine") +for d in ("dovodka", "editor_tier", "tenant_panel", "role_topology", "bank_arbitration"): + sys.path.insert(0, str(REPO / "eval" / d)) + +import battery as B # noqa: E402 + +# ── числа исходника: по одной реализации на письменность ────────────────────────────────────── +_RE_LAT_NUM = re.compile(r"\b[a-z][a-z-]*\b", re.I) +_EN_NUM = {"zero": 0, "one": 1, "two": 2, "three": 3, "four": 4, "five": 5, "six": 6, "seven": 7, + "eight": 8, "nine": 9, "ten": 10, "eleven": 11, "twelve": 12, "thirteen": 13, + "fourteen": 14, "fifteen": 15, "sixteen": 16, "seventeen": 17, "eighteen": 18, + "nineteen": 19, "twenty": 20, "thirty": 30, "forty": 40, "fifty": 50, "sixty": 60, + "seventy": 70, "eighty": 80, "ninety": 90, + # собирательные — зеркало русских «десяток/сотня/дюжина» в `battery._RU_NUM` + "dozen": 12, "score": 20} +_EN_MULT = {"hundred": 100, "thousand": 1_000, "million": 1_000_000, "billion": 1_000_000_000} +# ⚠ Японские разряды: 億 отличается от китайского 亿 НАЧЕРТАНИЕМ, и `battery._ZH_DIGITS` его не +# знает. Замер: `_zh_values("一億")` даёт {1} вместо {100000000}, `"三億五千万"` даёт {3, 5}. +# То есть на ja разбор не просто молчит, он выдаёт РАЗОБРАННЫЕ НЕВЕРНО величины. +_JA_EXTRA = {"億": 100_000_000, "〇": 0} + + +def values_en(source: str) -> set[int]: + """Величины английского исходника: арабские плюс числительные СЛОВАМИ. + + ⚠ Слова обязательны. Замер по 6 боевым единицам Кристоффа: цифрами 0 величин, словами 9. + Без разбора слов сторона исходника пуста, и тогда `check_numbers` не ловит потерь ВООБЩЕ, + а всякое русское числительное в выходе объявляет выдумкой. + """ + # ⚠ ЗНАК ПРЕПИНАНИЯ РАЗРЫВАЕТ составное числительное — иначе «three hundred, five hundred» + # копится в ОДНУ величину 3050700, и безупречный перевод получает потери плюс выдумку. + # Тот же дефект был в русском разборе `battery.check_numbers`; чинится симметрично. + out: set[int] = {int(x) for x in re.findall(r"\d+", source)} + acc = cur = 0 + for w in re.findall(r"[a-z]+(?:-[a-z]+)*|[^\s\w]", source.lower()): + if not w[0].isalpha(): + if cur or acc: + out.add(acc + cur) + acc = cur = 0 + continue + for part in w.split("-"): # twenty-three + if part in _EN_NUM: + cur += _EN_NUM[part] + elif part == "hundred": + cur = max(cur, 1) * 100 + elif part in _EN_MULT: + acc += max(cur, 1) * _EN_MULT[part] + cur = 0 + elif part in ("and", "a", "an"): + continue + else: + if cur or acc: + out.add(acc + cur) + acc = cur = 0 + if cur or acc: + out.add(acc + cur) + return {v for v in out if v} + + +def values_ja(source: str) -> set[int]: + """Величины японского исходника. + + ⚠ ПОЧЕМУ НЕ ДЕЛЕГИРУЕТСЯ В `battery._zh_values`, хотя запись иероглифическая и общая. + Тот гасит закрытый список КИТАЙСКИХ идиом (`battery._ZH_IDIOM`), и в нём есть `千万`: по-китайски + это и «десять миллионов», и «ни в коем случае», риг выбрал подавлять. По-японски `五千万` — + обычное число пятьдесят миллионов. Делегирование давало `三億五千万` → 300000005 вместо + 350000000, то есть НЕВЕРНО РАЗОБРАННУЮ величину, а не пропуск. Поймано селфтестом этого файла. + Плюс `億` отличается от китайского `亿` начертанием и `_ZH_DIGITS` его не знает. + """ + digits = {"零": 0, "〇": 0, "一": 1, "二": 2, "三": 3, "四": 4, "五": 5, + "六": 6, "七": 7, "八": 8, "九": 9} + src = source + # японских идиом-омонимов разряда в отобранном срезе нет; список пуст ОСОЗНАННО и объявлен + out: set[int] = set() + for m in re.finditer(r"[零〇一二三四五六七八九十百千万億]+", src): + run, total, section, cur = m.group(0), 0, 0, 0 + if run == "十": + continue + for ch in run: + if ch in digits: + cur = digits[ch] + elif ch == "十": + section += (cur or 1) * 10 + cur = 0 + elif ch == "百": + section += (cur or 1) * 100 + cur = 0 + elif ch == "千": + section += (cur or 1) * 1_000 + cur = 0 + elif ch == "万": + total += ((section + cur) or 1) * 10_000 + section = cur = 0 + elif ch == "億": + total = ((total + section + cur) or 1) * 100_000_000 + section = cur = 0 + v = total + section + cur + if v: + out.add(v) + wide = src.translate(str.maketrans("0123456789", "0123456789")) + out |= {int(x) for x in re.findall(r"[0-9]+", wide)} + return {v for v in out if v} + + +def values_zh(source: str) -> set[int]: + """Величины китайского исходника — поведение рига БЕЗ изменений (дефолт `battery`).""" + out = {int(x) for x in re.findall(r"\d+", source)} + return out | B._zh_values(source) # noqa: SLF001 + + +# ── конформность имени: чем проверяется транскрипция ────────────────────────────────────────── +_POLIVANOV_SYL = ("ka ki ku кэ ko sa si su se so ta ti tu te to na ni nu ne no ha hi hu he ho " + "ma mi mu me mo ya yu yo ra ri ru re ro wa n").split() + + +def name_conformant_zh_text(word: str) -> bool: + """Путь ТЕКСТА: кандидат уже опознан как имя и может стоять в косвенном падеже. + + Ровно то, что риг делает по умолчанию. Держится отдельно от банковой проверки: подстановка + строгой на этот путь возвращает 96 ложных склонённых на 2192 (battery.py:174). + """ + return B._translit_shape(word, stem_min_syllables=1) + + +def name_conformant_zh(word: str) -> bool: + """Палладий — конформность транскрипции С КИТАЙСКОГО. Дефолт рига, не меняется.""" + return B.palladius_conformant(word) + + +def name_conformant_none(_word: str) -> bool: + """Пара, для которой системы транскрипции НЕТ: класс флага снят, ложных мест не даём. + + ⚠ Возвращать `True` («всё конформно») — не заглушка, а единственный честный ответ: проверки + нет, значит нарушения не устанавливаются. Снятие класса объявляется в `Pair.absent` и + печатается в отчёте, чтобы `A1B` на этой паре не выдавался за `A1B` на китайской. + """ + return True + + +@dataclass(frozen=True) +class Pair: + """Всё, что харнесс знает о паре. Ни одно поле не имеет умолчания «как у китайского».""" + + key: str # "zh-ru" + lang_name: str # «китайском» — как язык исходника называется судье + bank_file: Path # банк терминов пары + prompt_pack: Path # каталог пар-промтов + brief_key: str # какой бриф подставлять в шаблон + source_script: re.Pattern # письменность исходника: детектор эха и адрес флага + spaced_source: bool # пробельная письменность → счёт термов по границе слова + values_in_source: object # callable(str) -> set[int] + name_conformant: object # callable(str) -> bool, путь БАНКА (словарная форма) + name_conformant_text: object # callable(str) -> bool, путь ТЕКСТА (терпит склонение) + mine_chars: str = "㐀-鿿" # класс знаков для n-граммного майнера непробельных письменностей + absent: tuple = field(default=()) # СНЯТЫЕ классы флагов — печатаются в отчёте + + def terms_in(self, source: str, bank: dict) -> list[str]: + """Термины банка, встречающиеся в исходнике. + + ⚠ Для пробельной письменности `source.count(term)` систематически ЗАНИЖАЕТ знаменатель: + `"Dawn"` встречается один раз, а `Dawn/dawn/dawning` — три. Поэтому здесь граница слова + и регистро-независимость, а для иероглифики — подстрока, как было. + """ + if not self.spaced_source: + return [t for t in bank if t in source] + return [t for t in bank + if re.search(rf"\b{re.escape(t)}\b", source, re.I)] + + def count_in_source(self, term: str, source: str) -> int: + if not self.spaced_source: + return source.count(term) + return len(re.findall(rf"\b{re.escape(term)}\b", source, re.I)) + + +HOME = Path.home() +ZH = Pair(key="zh-ru", lang_name="китайском", + bank_file=HOME / "books" / "tenant-panel" / "bank.json", + prompt_pack=REPO / "backend" / "prompts" / "zh-ru", + brief_key="BRIEF_ZH", source_script=re.compile(r"[㐀-鿿]"), spaced_source=False, + values_in_source=values_zh, name_conformant=name_conformant_zh, + name_conformant_text=name_conformant_zh_text) + +EN = Pair(key="en-ru", lang_name="английском", + bank_file=HOME / "books" / "role-topology" / "bank-en.json", + prompt_pack=REPO / "eval" / "role_topology" / "prompts" / "en-ru", + brief_key="BRIEF_EN", source_script=re.compile(r"[A-Za-z]"), spaced_source=True, + values_in_source=values_en, name_conformant=name_conformant_none, + name_conformant_text=name_conformant_none, + # ⚠ СНЯТО И ОБЪЯВЛЕНО: палладий — транскрипция с КИТАЙСКОГО. Замер агента на 72 боевых + # en-клетках: 219 ложных флагов, 3.04 на клетку («Хоакин», «Фабьена», «Огюстен»). + # Флаг несёт цитату, поэтому гейт «флаг без адреса» его пропускает, и фиксер чинил бы + # правильные имена. Для en системы транскрипции в проекте нет — класс снят. + absent=("палладий: системы транскрипции для en в проекте нет",)) + +JA = Pair(key="ja-ru", lang_name="японском", + bank_file=HOME / "books" / "dovodka" / "bank-ja.json", + prompt_pack=REPO / "eval" / "role_topology" / "prompts" / "ja-ru", + brief_key="BRIEF_JA", source_script=re.compile(r"[㐀-鿿ぁ-ゟ゠-ヿ]"), spaced_source=False, + # ⚠ КАТАКАНА ОБЯЗАТЕЛЬНА в майнинге: кандзи-класс её не видит, а второй мир книги + # записан катаканой почти целиком (ケイン ×206 — главный герой после перерождения, + # ヨルガ ×89). С одним кандзи-классом банк пары остался бы без главных имён, и + # канон-ось Д6 мерила бы покрытие мимо всего, что важно. + mine_chars="㐀-鿿ァ-ヺー", + values_in_source=values_ja, name_conformant=name_conformant_none, + name_conformant_text=name_conformant_none, + # ⚠ СНЯТО И ОБЪЯВЛЕНО: поливановской проверки в проекте нет, а палладиевская флагует + # корректные японские имена («Рэнтаро», «Хёго» — проверено). Заводить её надо отдельной + # работой; до тех пор класс снят, а не подменён китайским. + absent=("палладий: для ja нужна поливановская проверка, её в проекте нет",)) + +PAIRS = {p.key: p for p in (ZH, EN, JA)} + + +def selftest() -> int: + bad = 0 + + def ck(n: str, ok: bool, d: str = "") -> None: + nonlocal bad + bad += not ok + print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else "")) + + # ⚠ ГЛАВНЫЙ АССЕРТ: китайское поведение НЕ ДОЛЖНО СДВИНУТЬСЯ. Пар-провайдер вводится ради + # переносимости, и если он меняет числа уже снятой оси, вводить его нельзя. + zh_src = "秦风走了三千里,见到了一万人。还有两千三百个。" + ck("zh: величины исходника те же, что у рига", + ZH.values_in_source(zh_src) == ({int(x) for x in re.findall(r'\d+', zh_src)} + | B._zh_values(zh_src)), # noqa: SLF001 + str(sorted(ZH.values_in_source(zh_src)))) + ck("zh: конформность имени — палладий рига", ZH.name_conformant("Цинь") == B.palladius_conformant("Цинь")) + ck("zh: ТЕКСТОВАЯ проверка = дефолту рига (иначе сдвинет снятую ось)", + all(ZH.name_conformant_text(w) == B._translit_shape(w, stem_min_syllables=1) + for w in ("Чжэна", "Мочэнем", "Цинь", "Фэну", "Сюэ"))) + ck("zh: текстовая ТЕРПИТ склонение, банковая — НЕТ", + ZH.name_conformant_text("Чжэна") and not ZH.name_conformant("Чжэна")) + ck("zh: письменность ловит иероглифы", bool(ZH.source_script.search("秦风"))) + ck("zh: счёт терма подстрокой (как было)", ZH.count_in_source("秦风", zh_src) == 1) + + en_src = "He walked three thousand miles and met five hundred men. Twenty-three of them died." + v = EN.values_in_source(en_src) + ck("en: числительные СЛОВАМИ разобраны", {3000, 500, 23} <= v, str(sorted(v))) + ck("en: счёт терма по границе слова и без регистра", + EN.count_in_source("dawn", "Dawn came; the dawn was red; dawning.") == 2, + str(EN.count_in_source("dawn", "Dawn came; the dawn was red; dawning."))) + ck("en: палладий СНЯТ, ложных мест не даёт", EN.name_conformant("Хоакин") is True) + ck("en: снятие класса ОБЪЯВЛЕНО", bool(EN.absent)) + ck("en: письменность — латиница", bool(EN.source_script.search("Dawn")) and + not EN.source_script.search("秦风")) + + ja_src = "三億五千万円。一億の民。7つの村。" + vj = JA.values_in_source(ja_src) + ck("ja: 億 разобран верно (не как 1)", 350_000_000 in vj and 100_000_000 in vj, + str(sorted(vj))) + ck("ja: полноширинные цифры разобраны", 7 in vj) + ck("ja: письменность ловит кану (эхо-мина каной)", + bool(JA.source_script.search("これはテスト"))) + ck("ja: палладий СНЯТ и объявлен", JA.name_conformant("Рэнтаро") is True and bool(JA.absent)) + + ck("банк пары СУЩЕСТВУЕТ (иначе канон-ось пуста, а A4 неотличим от A0)", + all(p.bank_file.exists() for p in PAIRS.values()), + "нет: " + ", ".join(p.key for p in PAIRS.values() if not p.bank_file.exists())) + ck("у каждой пары свой банк", len({p.bank_file for p in PAIRS.values()}) == len(PAIRS)) + ck("у каждой пары свой каталог промтов", + len({p.prompt_pack for p in PAIRS.values()}) == len(PAIRS)) + print(f"\n{'ПАР-ПРОВАЙДЕР ГОДЕН' if not bad else f'ПРОВАЛОВ: {bad}'}") + return bad + + +def main() -> int: + if "--selftest" in sys.argv: + return selftest() + print(f"{'пара':8s}{'язык':14s}{'письменность':>14s}{'банк':>34s} снятые классы") + for k, p in PAIRS.items(): + print(f"{k:8s}{p.lang_name:14s}{('пробельная' if p.spaced_source else 'иероглифика'):>14s}" + f"{('есть' if p.bank_file.exists() else '⛔ НЕТ'):>34s} " + f"{'; '.join(p.absent) or '—'}") + return 0 + + +if __name__ == "__main__": + sys.exit(1 if main() else 0) diff --git a/eval/dovodka/plan.py b/eval/dovodka/plan.py index e86bfe31..f6ca04fe 100644 --- a/eval/dovodka/plan.py +++ b/eval/dovodka/plan.py @@ -25,7 +25,12 @@ from pathlib import Path T = Path.home() / "books" / "tenant-panel" E = Path.home() / "books" / "editor-tier" -PACK_CEILING = 4.50 +# ⚠ Потолок берётся ИЗ КАССЫ, а не дублируется числом: копия отстала от санкций владельца +# ($4.50 против фактических $6.15) и смета сверялась с несуществующей границей. +import importlib.util as _il # noqa: E402 +_sp = _il.spec_from_file_location("_money_d", Path(__file__).resolve().parent / "money_d.py") +_md = _il.module_from_spec(_sp); _sp.loader.exec_module(_md) +PACK_CEILING = _md.PACK_CEILING def measured() -> dict[tuple[str, str], float]: diff --git a/eval/dovodka/sud.py b/eval/dovodka/sud.py index 15487030..bb384077 100644 --- a/eval/dovodka/sud.py +++ b/eval/dovodka/sud.py @@ -72,6 +72,11 @@ TASKS1, TASKS2 = WORK / "p1-tasks", WORK / "p2-tasks" ANSW1, ANSW2 = WORK / "p1-answers", WORK / "p2-answers" VOTES = WORK / "votes" KEYS = Path.home() / "books" / "dovodka" / "blind-keys-d4" # НЕ в WORK: судья туда не ходит +# ⚠ ПЕРЕВОДИМ РИГ НА КЛЮЧИ ФАЗЫ СРАЗУ, а не только внутри `emit`. Селфтест зовёт `AJ._plant` +# напрямую и при дефолтном `AJ.KEYS` строил приманку ПОСАДКАМИ ЧУЖОГО ПАКА (7 против 6, правила +# разные) — то есть гейт «грубый декой грубее маржевого» сертифицировал не тот текст, который +# реально видели судьи. Ровно этот класс уже был пойман в своде (`itog_d4.py:53`), здесь остался. +AJ.KEYS = KEYS # Армы панели Д4. `A1` (только канон-флаги) — описательный, в первичное семейство не входит. ARMS_D4 = ("A0", "A1B", "A2", "A3", "A4", "A1") @@ -249,11 +254,25 @@ def selftest() -> int: autojunk=False).ratio()) ck("маржевый декой ТОНКИЙ (меняет <1% знаков)", sims and min(sims) > 0.99, f"схожесть мин {min(sims):.4f}" if sims else "нет посадок") - # грубый декой рига обязан оставаться ГРУБЫМ — иначе оба контроля меряют одно - coarse, nc = AJ._plant(C.base_a0(u["uid"])) # noqa: SLF001 - cs = difflib.SequenceMatcher(None, C.base_a0(u["uid"]), coarse, autojunk=False).ratio() - ck("грубый декой ГРУБЕЕ маржевого", nc >= 2 and cs < (min(sims) if sims else 1.0), - f"грубый {cs:.4f} против маржевого {min(sims):.4f}" if sims else "") + # Грубый декой рига обязан оставаться ГРУБЫМ — иначе оба контроля меряют одно. + # ⚠ Сравнение ПОЭДИНИЧНОЕ. Прежняя редакция брала грубый декой на ОДНОЙ единице и сравнивала + # с МИНИМУМОМ схожести маржевого по всем — разные величины, и вердикт зависел от того, какая + # единица попалась. Теперь оба декоя считаются на одном и том же тексте, и требуется, чтобы + # грубый был грубее на КАЖДОЙ единице: гейт от этого строже, а не мягче. + worse = tot = 0 + for x in us: + b = C.base_a0(x["uid"]) + if not b.strip(): + continue + ct, cn = AJ._plant(b) # noqa: SLF001 + mt, mn = margin_plant(b) + if cn < 2 or mn < 1: + continue + tot += 1 + worse += (difflib.SequenceMatcher(None, b, ct, autojunk=False).ratio() + < difflib.SequenceMatcher(None, b, mt, autojunk=False).ratio()) + ck("грубый декой ГРУБЕЕ маржевого на КАЖДОЙ единице", tot and worse == tot, + f"грубее на {worse} из {tot}") # половины пола идут в РАЗНЫЕ наборы и не совпадают побайтно pairs = [(floor_pair(x, 1), floor_pair(x, 2)) for x in us] pairs = [p for p in pairs if p[0] and p[1]] diff --git a/eval/role_topology/battery.py b/eval/role_topology/battery.py index c052fdb2..2c05216d 100644 --- a/eval/role_topology/battery.py +++ b/eval/role_topology/battery.py @@ -97,6 +97,27 @@ _RE_CYR_WORD = re.compile(r"[А-Яа-яЁё]+") _RE_LAT_WORD = re.compile(r"[A-Za-z]+") _RE_HAN = re.compile(r"[㐀-鿿]") +# ⚠⚠ ТРИ КРЮЧКА СТОРОНЫ ИСХОДНИКА — заведены фазой Д 14.08 под перенос на другие пары. +# Дефолты РАВНЫ прежнему поведению, поэтому китайские числа не двигаются; это проверяется +# ассертом в `para.py --selftest` и пере-снятием гейтов пака 21. +# Почему крючки, а не ветвление по паре внутри проверок: ветвление по паре в коде запрещено +# каноном проекта (CLAUDE.md), пара обязана жить в данных. Замер, ради которого это сделано: +# на английском исходнике `_zh_values` даёт ПУСТО, поэтому `lost_n ≡ 0` (потери не ловятся +# вовсе), а `invented_n` объявляет выдумкой каждое русское числительное ≥100 — 12 ложных на +# 72 боевых клетках. На японском `億` не опознаётся и величина разбирается НЕВЕРНО. +SOURCE_VALUES_HOOK = None # callable(str) -> set[int]; None = китайский разбор рига +NAME_CONFORMANT_TEXT_HOOK = None # callable(str) -> bool, путь ТЕКСТА (терпит склонение) +NAME_CONFORMANT_BANK_HOOK = None # callable(str) -> bool, путь БАНКА (словарная форма, строго) # callable(str) -> bool; None = палладий рига +SOURCE_SCRIPT_HOOK = None # compiled regex; None = ханьцзы рига + + +def configure_pair(**kw) -> None: + """Переключить сторону исходника на другую пару. Ключи — имена крючков этого модуля.""" + for k, v in kw.items(): + if k not in globals(): + raise SystemExit(f"⛔ battery.configure_pair: неизвестный крючок {k!r}") + globals()[k] = v + def normalize(text: str) -> str: """NFC + снятие комбинирующих знаков НА КИРИЛЛИЧЕСКОЙ базе (см. баннер модуля). @@ -155,9 +176,17 @@ def check_palladius(final: str, bank_dst: set[str]) -> dict: # («Чжэна», «Мочэнем»): русское окончание слогом Палладия не является. Порог стема здесь # 1 слог, а не 2: кандидат УЖЕ опознан как имя, а односложные транскрипции нормальны # (Чжэн · Фан · Сун) — при пороге 2 одно «Чжэна» давало 24 ложных. - if not _translit_shape(w, stem_min_syllables=1): + conform = (NAME_CONFORMANT_TEXT_HOOK(w) if NAME_CONFORMANT_TEXT_HOOK is not None + else _translit_shape(w, stem_min_syllables=1)) + if not conform: bad_text.append(w) - bad_bank = [d for d in bank_dst if _RE_CYR_WORD.search(d) and not palladius_conformant(d)] + # ⚠ ДВА РАЗНЫХ ХУКА, не один. Текстовый путь обязан ТЕРПЕТЬ склонение («Чжэна» — верная + # транскрипция в родительном), банковый — нет: строка банка даётся в словарной форме, и + # послабление там пропускало бы порчу. Один общий хук схлопывал обе проверки в строгую и + # возвращал класс «96 ложных склонённых» (строка 174), то есть тихо сдвигал уже снятые + # числа китайской оси. Поймано вычиткой 14.08 ДО того, как проводка пары это включила. + _conf = NAME_CONFORMANT_BANK_HOOK or palladius_conformant + bad_bank = [d for d in bank_dst if _RE_CYR_WORD.search(d) and not _conf(d)] return dict(name_candidates=checked, nonconformant_text=len(bad_text), nonconformant_text_words=sorted(set(bad_text))[:12], nonconformant_bank=len(bad_bank)) @@ -171,7 +200,7 @@ def check_cjk_leak(final: str, threshold: float = 0.02) -> dict: видит. Здесь считается и доля, и АБСОЛЮТНОЕ число — второе и есть чувствительная метрика. """ t = normalize(final) - han = _RE_HAN.findall(t) + han = (SOURCE_SCRIPT_HOOK or _RE_HAN).findall(t) letters = [c for c in t if c.isalpha()] share = len(han) / max(1, len(letters)) return dict(han_chars=len(han), han_share=round(share, 5), @@ -315,8 +344,11 @@ def check_numbers(source: str, final: str) -> dict: МНОЖЕСТВА величин: пропавшая или появившаяся величина есть дефект фактуры. ⚠ Проверка НЕ ловит переставленные величины при совпадающем множестве — объявлено. """ - src_vals = set(int(x) for x in re.findall(r"\d+", source)) - src_vals |= _zh_values(source) + if SOURCE_VALUES_HOOK is not None: + src_vals = set(SOURCE_VALUES_HOOK(source)) + else: + src_vals = set(int(x) for x in re.findall(r"\d+", source)) + src_vals |= _zh_values(source) dst_vals = set(int(x) for x in re.findall(r"\d+", normalize(final))) for m in re.finditer(r"(\d+)\s*(тысяч\w*|миллион\w*)", normalize(final), re.I): mult = 1_000 if m.group(2).lower().startswith("тысяч") else 1_000_000 @@ -325,8 +357,18 @@ def check_numbers(source: str, final: str) -> dict: # единице — почти все ложно: русский художественный текст пишет «пятьсот», а не «500». # Разбор идёт по ЛЕММЕ (склонение снимает морфология), накопление — как в русском счёте: # сотни+десятки+единицы складываются, множитель умножает накопленное. + # ⚠ ПУНКТУАЦИЯ РАЗРЫВАЕТ СОСТАВНОЕ ЧИСЛИТЕЛЬНОЕ. Поймано вычиткой 14.08: `words()` знаки + # выбрасывает, поэтому «триста, пятьсот, семьсот» накапливалось в ОДНУ величину 1500 — + # исходные 300/500/700 объявлялись потерянными, а 1500 выдуманной. Четыре ложных места на + # ровном месте, и `numbers.lost/invented` входят в адреса фиксера A1B, то есть он ехал их + # «чинить». Ниже поток идёт со знаками, и любой знак сбрасывает накопитель. acc, cur = 0, 0 - for w in words(final): + for w in re.findall(r"[^\W\d_]+|[^\s\w]", normalize(final)): + if not w[0].isalpha(): + if cur or acc: + dst_vals.add(acc + cur) + acc = cur = 0 + continue # Первый разбор — не всегда числительный: у «десятки» это «десятка», а не «десяток». # Берём первую лемму, которая ВООБЩЕ числительное, иначе откатываемся на первый разбор. lemmas = [p.normal_form for p in _MORPH.parse(w.lower())] diff --git a/eval/role_topology/pair_en.py b/eval/role_topology/pair_en.py index 385e4906..695e7fb6 100644 --- a/eval/role_topology/pair_en.py +++ b/eval/role_topology/pair_en.py @@ -126,7 +126,49 @@ def is_prose(c: str) -> bool: return len(re.findall(r"[.!?]", c)) / max(1, len(c)) * 1000 >= 8.0 -def units() -> list[dict]: +MANIFEST = OUT / "manifest-en.json" + + +def units(n_per_stratum: int | None = None, freeze: bool = False) -> list[dict]: + """Единицы английского среза — СТРОГО из приколотого манифеста. + + ⚠ Прошлая редакция этого прикола (14.08) была декоративной и вредной, и оба дефекта нашла + вычитка. Первый: подпись считалась как sha1 от `source`, а `uid` — как sha1 от `source.strip()` + при уже обрезанных текстах, то есть подпись выводилась ИЗ ТОГО ЖЕ, что и ключ, и расхождение + было непредставимо в принципе. Второй, дороже: манифест перезаписывался при ЛЮБОМ вызове, + включая чтение, — дефолтный `units()` из `material.units_en()` ужимал приколотые 16 единиц + оси Д3 до своих 12 и молча ронял прикол четырёх. + + Теперь: чтение НИКОГДА не пишет, заморозка — явный акт (`freeze=True`), а расхождением + считается отсутствие приколотого uid среди пересчитанных кандидатов. Это проверяет то, ради + чего прикол заводился: смена `UNIT_CHARS`, правка `is_prose` или до-качка epub меняют uid, + приколотый исчезает из пула — и покупка останавливается вместо тихой смены текстов. + + Наборы разного размера живут порознь: ось эксп-21 заморожена на 12 единиц, ось Д3 на 16. + """ + n = n_per_stratum or N_PER_STRATUM + picked = _units_raw(n) + man = json.loads(MANIFEST.read_text(encoding="utf-8")) if MANIFEST.exists() else {} + sets = man.get("sets", {}) + if freeze: + sets[str(n)] = [u["uid"] for u in picked] + MANIFEST.parent.mkdir(parents=True, exist_ok=True) + MANIFEST.write_text(json.dumps({"sets": sets}, ensure_ascii=False, indent=1), + encoding="utf-8") + return picked + want = sets.get(str(n)) + if want is None: + raise SystemExit(f"⛔ набор n_per_stratum={n} не заморожен в {MANIFEST}: " + f"прогнать pair_en.py --freeze {n} до покупок") + have = {u["uid"]: u for u in picked} + miss = [u for u in want if u not in have] + if miss: + raise SystemExit(f"⛔ ЕДИНИЦЫ РАЗЪЕХАЛИСЬ С МАНИФЕСТОМ en: нет {miss[:3]} — " + "источник или отбор изменились под купленными клетками, СТОП") + return [have[u] for u in want] + + +def _units_raw(n_per_stratum: int | None = None) -> list[dict]: """12 единиц: 6 с французским слоем, 6 без. Отбор детерминированный — по длине внутри страты. Порог страты `fr`: ≥4 токена с диакритикой, из них ≥2 РАЗНЫХ. Одиночное `façade` французским @@ -142,6 +184,9 @@ def units() -> list[dict]: elif not toks: plain.append(c) out = [] + n_take = n_per_stratum or N_PER_STRATUM + if n_take > min(len(fr), len(plain)): + raise SystemExit(f"⛔ страта меньше запроса: fr={len(fr)} plain={len(plain)} < {n_take}") for name, pool in (("fr", fr), ("plain", plain)): # ⚠ Ключ сортировки — (длина, uid), а НЕ одна длина. Поймано исполнением 07.08: два # вызова подряд вернули разные наборы единиц. Причина — `sorted(set(...), key=len)`: @@ -149,8 +194,11 @@ def units() -> list[dict]: # процессам (хеш-сид). Отбор при этом выглядел детерминированным. Последствие было бы # тихим и дорогим: армы покупаются на один набор, судейство идёт по другому. pool = sorted(set(pool), key=lambda c: (len(c), uid_of(c))) - lo = (len(pool) - N_PER_STRATUM) // 2 - for c in pool[lo:lo + N_PER_STRATUM]: + # Окно ЦЕНТРИРОВАНО, поэтому расширение страты вкладывает прежний набор в новый: + # lo(n+2) = lo(n) − 1 при любой чётности длины пула, значит купленные единицы остаются + # в наборе и не переназначаются. Проверяется вложением ниже по манифесту. + lo = (len(pool) - n_take) // 2 + for c in pool[lo:lo + n_take]: out.append(dict(source=c, uid=uid_of(c), stratum=name, fr_tokens=sorted(set(_RE_FR.findall(c))))) return out diff --git a/eval/role_topology/prompts/en-ru/terminologist.md b/eval/role_topology/prompts/en-ru/terminologist.md new file mode 100644 index 00000000..aee3da0c --- /dev/null +++ b/eval/role_topology/prompts/en-ru/terminologist.md @@ -0,0 +1,86 @@ + +Ты — терминолог издательского перевода с языка «{{source_lang}}» на язык «{{target_lang}}». +Книга: «{{title}}». Жанр: {{genre}}. Аудитория: {{audience}}. + +Твоя задача — НЕ переводить текст. Тебе дан список терминов книги (имена, топонимы, титулы, реалии), +и по каждому — контексты из ИСХОДНИКА и варианты, которые черновые переводчики уже предложили. +Ты видишь всю книгу сразу; черновики видели её по кускам и потому расходятся. Твоё дело — выбрать +ОДИН вариант на термин, единый для всей книги. + +Правила перевода (translation brief): +- Хонорифики: {{honorifics}}. Транскрипция имён и реалий: {{transcription}}. +- Баланс форенизация/доместикация (0 — полная адаптация, 1 — сохранение чужого): {{venuti}}. + +Как решать: +- Опирайся на КОНТЕКСТЫ (строки `ctx:`) — они показывают, чем термин является в тексте. Имя человека, + название места, титул и предмет переводятся по-разному, и тип в строке `type:` может быть неточен. +- Варианты черновиков (строка `drafts:`) — это свидетельства, а не голосование. Частый вариант может + быть частой ошибкой; редкий может быть единственным верным. Расхождение вариантов — сигнал, что термин + трудный, а не подсказка, какой ответ правильный. +- Блок ⟦TM-CANON⟧ — УЖЕ ПОДПИСАННЫЕ владельцем термины этой книги. Это закон, а не совет: если термин + из списка содержит подписанный элемент исходника, перевод ОБЯЗАН нести подписанный перевод этого + элемента. Противоречить ⟦TM-CANON⟧ нельзя, даже если черновики предложили другое и даже если тебе + кажется, что твой вариант красивее: единство книги важнее отдельной удачной формулировки. +- Имена и топонимы передавай транскрипцией по заданной системе; титулы, звания и реалии переводи + ПО СМЫСЛУ, а не транслитерацией. +- Сохраняй родство однокоренных терминов: если несколько терминов делят элемент исходника, их переводы + должны делить соответствующий элемент. +- Строка `related:` называет термины, с которыми этот пересекается по написанию. Это РАЗНЫЕ термины — + переводи каждый отдельно, но следи, чтобы переводы не противоречили друг другу. +- Давай СЛОВАРНУЮ форму (именительный падеж, единственное число), без пояснений и скобок. +- Если по данным контекстам ты не можешь выбрать перевод уверенно — верни ⟦TM-NO-DST⟧. Это нормальный + ответ: непереведённый термин останется на ручную подпись, а выдуманный попадёт в книгу. + +Единицы и приёмы (общие для en→ru): +- ФРАНЦУЗСКИЙ СЛОЙ: часть имён и реалий книги — французского происхождения внутри английской прозы. Такие имена и топонимы канонизируй по ФРАНЦУЗСКОМУ чтению (Thibault — Тибо, не «Тибаулт»), лексику с русской традицией передачи французского — этой традицией (chevalier — шевалье, château — шато); остальные имена — практической транскрипцией с английского. Английское прочтение французского имени в черновиках — частая ошибка, а не довод. Вставные французские формулы (Oui, Merci), попавшие в список, — не термины: верни для них ⟦TM-NO-DST⟧, их передачу решает переводчик по правилам пары, а не банк. +- ЗАГЛАВНАЯ БУКВА — не доказательство имени: в английском её носит и имя собственное, и первое слово предложения, поэтому в списке могут оказаться обычные слова языка (Though, Yet, Well). Терминность решают контексты, а не заглавная. Общеязыковое слово не канонизируй — верни ⟦TM-NO-DST⟧: попав в банк, оно превратит проверку терминологии книги в проверку словаря. Устойчивая реалия книги под заглавной — полноценный термин: переводи её по смыслу, с прописными по русской норме, не копируя английскую капитализацию Каждого Слова. +- ПЕРВОЕ ПОЛЕ ответа повторяй В ТОЧНОСТИ как в списке: с той же заглавной, без артикля, без смены числа — по этой строке термин ищется в исходнике, и изменённая форма молча выпадет из проверки покрытия. + +Формат ответа — по одной строке на термин, ровно ТРИ поля, разделённые СИМВОЛОМ ТАБУЛЯЦИИ: +первое поле — термин исходника, второе — перевод, третье — твоя уверенность в этом переводе, +целое число от 0 до 100. + +Уверенность — это оценка ТВОЕГО решения по данным контекстам, а не оценка термина: 90 — контексты +однозначны и вариант очевиден; 40 — контекстов мало или они противоречивы, и ты выбрал наименее плохой. +Она ни на что не влияет, кроме порядка, в котором человек будет просматривать список: сначала он посмотрит +то, в чём ты сам не уверен. Занижать её «на всякий случай» так же бесполезно, как завышать. + +Пример строки ответа (символы между полями — настоящие табуляции): + +Thibault Тибо 95 + +Никаких заголовков, нумерации, комментариев и markdown. Термины, которых нет в списке, не добавляй. + +---USER--- +Термины книги: + +{{text}} diff --git a/eval/role_topology/prompts/ja-ru/terminologist.md b/eval/role_topology/prompts/ja-ru/terminologist.md new file mode 100644 index 00000000..8e2967ae --- /dev/null +++ b/eval/role_topology/prompts/ja-ru/terminologist.md @@ -0,0 +1,89 @@ + +Ты — терминолог издательского перевода с языка «{{source_lang}}» на язык «{{target_lang}}». +Книга: «{{title}}». Жанр: {{genre}}. Аудитория: {{audience}}. + +Твоя задача — НЕ переводить текст. Тебе дан список терминов книги (имена, топонимы, титулы, реалии), +и по каждому — контексты из ИСХОДНИКА и варианты, которые черновые переводчики уже предложили. +Ты видишь всю книгу сразу; черновики видели её по кускам и потому расходятся. Твоё дело — выбрать +ОДИН вариант на термин, единый для всей книги. + +Правила перевода (translation brief): +- Хонорифики: {{honorifics}}. Транскрипция имён и реалий: {{transcription}}. +- Баланс форенизация/доместикация (0 — полная адаптация, 1 — сохранение чужого): {{venuti}}. + +Как решать: +- Опирайся на КОНТЕКСТЫ (строки `ctx:`) — они показывают, чем термин является в тексте. Имя человека, + название места, титул и предмет переводятся по-разному, и тип в строке `type:` может быть неточен. +- Варианты черновиков (строка `drafts:`) — это свидетельства, а не голосование. Частый вариант может + быть частой ошибкой; редкий может быть единственным верным. Расхождение вариантов — сигнал, что термин + трудный, а не подсказка, какой ответ правильный. +- Блок ⟦TM-CANON⟧ — УЖЕ ПОДПИСАННЫЕ владельцем термины этой книги. Это закон, а не совет: если термин + из списка содержит подписанный элемент исходника, перевод ОБЯЗАН нести подписанный перевод этого + элемента. Противоречить ⟦TM-CANON⟧ нельзя, даже если черновики предложили другое и даже если тебе + кажется, что твой вариант красивее: единство книги важнее отдельной удачной формулировки. +- Имена и топонимы передавай транскрипцией по заданной системе; титулы, звания и реалии переводи + ПО СМЫСЛУ, а не транслитерацией. +- Сохраняй родство однокоренных терминов: если несколько терминов делят элемент исходника, их переводы + должны делить соответствующий элемент. +- Строка `related:` называет термины, с которыми этот пересекается по написанию. Это РАЗНЫЕ термины — + переводи каждый отдельно, но следи, чтобы переводы не противоречили друг другу. +- Давай СЛОВАРНУЮ форму (именительный падеж, единственное число), без пояснений и скобок. +- Если по данным контекстам ты не можешь выбрать перевод уверенно — верни ⟦TM-NO-DST⟧. Это нормальный + ответ: непереведённый термин останется на ручную подпись, а выдуманный попадёт в книгу. + +Единицы и приёмы (общие для ja→ru): +- ТРАНСКРИПЦИЯ японских имён и реалий — строго по системе Поливанова: си, дзи, тя, сю, «э» после согласных (慎二 — Синдзи), а не калькой с английской ромадзи (не «Шинджи», не «-е» там, где Поливанов даёт «-э»). Черновики регулярно предлагают ромадзи-формы — их частота не довод: канон обязан быть поливановским. +- КАТАКАНА обычно означает заимствование или чужое имя: восстанови слово-источник и передай его по правилам ЯЗЫКА-ИСТОЧНИКА, а не послоговой калькой японского произношения. Европейское имя — его европейским чтением (アルフレッド — Альфред, не «Аруфурэддо»), освоенное заимствование — устоявшимся русским словом (ギルド — гильдия, не «гирудо»), выдуманное имя без ясного источника — практической передачей звучания без вставных гласных японской фонотактики. +- ЧАСТОТНЫЙ СПИСОК кандидатов тянет и общеязыковую лексику (слова уровня 言葉, 自分, 視線) — это не термины книги. Общеязыковое слово не канонизируй — верни ⟦TM-NO-DST⟧: попав в банк, оно превратит проверку терминологии книги в проверку словаря. Терминность решают контексты: имя, топоним, титул, устойчивая реалия книги. + +Формат ответа — по одной строке на термин, ровно ТРИ поля, разделённые СИМВОЛОМ ТАБУЛЯЦИИ: +первое поле — термин исходника, второе — перевод, третье — твоя уверенность в этом переводе, +целое число от 0 до 100. + +Уверенность — это оценка ТВОЕГО решения по данным контекстам, а не оценка термина: 90 — контексты +однозначны и вариант очевиден; 40 — контекстов мало или они противоречивы, и ты выбрал наименее плохой. +Она ни на что не влияет, кроме порядка, в котором человек будет просматривать список: сначала он посмотрит +то, в чём ты сам не уверен. Занижать её «на всякий случай» так же бесполезно, как завышать. + +Пример строки ответа (символы между полями — настоящие табуляции): + +慎二 Синдзи 95 + +Никаких заголовков, нумерации, комментариев и markdown. Термины, которых нет в списке, не добавляй. + +---USER--- +Термины книги: + +{{text}} diff --git a/eval/tenant_panel/bank.py b/eval/tenant_panel/bank.py index 52b7b519..eed8d8fc 100644 --- a/eval/tenant_panel/bank.py +++ b/eval/tenant_panel/bank.py @@ -43,6 +43,45 @@ from roster import CANDIDATES # noqa: E402 load_dotenv(REPO / "eval" / ".env") OUT = MONEY.OUT +# ⚠ ПАРА — ПАРАМЕТР, а не константа. Умолчание zh-ru сохраняет прежнее поведение побайтно; +# `configure(pair)` переводит модуль на другую пару, ничего не ветвя по языку в логике. +_PAIR = None # заполняется лениво, чтобы импорт не тянул провайдер + + +def pair(): + global _PAIR + if _PAIR is None: + import importlib.util # noqa: PLC0415 + sp = importlib.util.spec_from_file_location( + "para", REPO / "eval" / "dovodka" / "para.py") + mod = importlib.util.module_from_spec(sp) + sys.modules.setdefault("para", mod) + sp.loader.exec_module(mod) + _PAIR = mod.ZH + return _PAIR + + +def configure(p, material=None) -> None: + """Перевести банк на пару `p` (объект `para.Pair`); `material` — callable() -> str. + + ⚠ Материал передаётся ЯВНО. Иначе `source_text()` тихо остаётся на китайских главах, и + терминолог получает китайскую книгу под видом английской — банк собрался бы «успешно» и + оказался мусором, а покрытие на выходе арма было бы посчитано против него же. + """ + global _PAIR, TERMINOLOGIST, _MATERIAL + _PAIR = p + _MATERIAL = material + # Порядок поиска: пар-пакет полигона → боевой промт бэкенда. Обратный порядок молча + # подсунул бы китайского терминолога английской книге; `backend/` — чужая зона, туда пишет + # бэкенд-сессия, поэтому новые пары живут в пакете полигона. + cands = [p.prompt_pack / "terminologist.md", + REPO / "backend" / "prompts" / p.key / "terminologist.md"] + TERMINOLOGIST = next((c for c in cands if c.exists()), None) + if TERMINOLOGIST is None: + raise SystemExit("⛔ нет промта терминолога для пары " + p.key + ": искал\n " + + "\n ".join(str(c) for c in cands)) + + TERMINOLOGIST = REPO / "backend" / "prompts" / "zh-ru" / "terminologist.md" # Терминолог — вспомогательный инструмент, не арм. Берётся дешёвая модель с управляемым # размышлением: у боевого `deepseek-v4-flash` бэнк-роли ручки эффорта не имеют и упираются в @@ -56,12 +95,26 @@ KWIC_N, KWIC_W = 3, 46 # бывает. Список закрытый и объявлен ДО майнинга. STOP_EDGE = set("的了是在和有我你他她们不就都而及与也很到说着过又却把被将从对为以之其这那一二三四五" "六七八九十个人上下中大小时年日月来去出可能会要没什么样如此但只还更最") +# Начала предложений и служебные слова английского: заглавная буква у них позиционная, а не +# именная. Список закрытый и объявлен ДО майнинга, как и китайский. +STOP_WORD = {"The", "And", "But", "For", "She", "His", "Her", "They", "That", "This", "With", + "When", "What", "Then", "There", "Their", "Was", "Not", "You", "Who", "How", + "One", "All", "Now", "Him", "Its", "Had", "Have", "From", "Been", "Would"} STOP_WHOLE = {"自己", "已经", "现在", "眼中", "心中", "一个", "什么", "这样", "那些", "如今", "顿时", "此刻", "随后", "所以", "因为", "如果", "虽然", "而且", "然后", "似乎"} +_MATERIAL = None + + +def _term_tag() -> str: + """Тег покупки терминолога. Несёт пару, иначе кэш `purchase` смешивает пары.""" + P = pair() + return "tp0-bank-terminolog" if P.key == "zh-ru" else f"tp0-bank-terminolog-{P.key}" + + def source_text() -> str: - return "\n".join(c["text"] for c in M.chapters()) + return _MATERIAL() if _MATERIAL else "\n".join(c["text"] for c in M.chapters()) def mine() -> list[tuple[str, int]]: @@ -72,8 +125,24 @@ def mine() -> list[tuple[str, int]]: """ text = source_text() cnt: Counter[str] = Counter() + if pair().spaced_source: + # ⚠ У пробельной письменности n-граммный майнер по иероглифам не находит НИЧЕГО и молча + # отдаёт терминологу пустой список — банк вышел бы пустым, а покрытие ложно-стопроцентным. + # Кандидаты здесь — заглавные слова и биграммы заглавных: имена, титулы, топонимы. + # Диакритика ОБЯЗАТЕЛЬНА в классе: французский слой книги (François ×517, Fabién ×180, + # Château) ASCII-классом не майнится вовсе, а это ровно тот слой, ради которого срез + # стратифицирован. `\b` с юникодными буквами работает, класс — нет. + for m in re.finditer(r"(?= MIN_COUNT][:N_TERMS] for n in (4, 3, 2): - for m in re.finditer(r"[㐀-鿿]{%d}" % n, text): + for m in re.finditer(r"[%s]{%d}" % (pair().mine_chars, n), text): cnt[m.group(0)] += 1 cand = {w: c for w, c in cnt.items() if c >= MIN_COUNT and w not in STOP_WHOLE @@ -115,7 +184,9 @@ def cmd_mine() -> None: def cmd_ask() -> None: core, _few, user = PR.load_template(TERMINOLOGIST) - brief = PR.BRIEF_ZH + # ⚠ Бриф — ПАРЫ, а не всегда китайский: иначе терминолог en/ja получал китайский бриф + # (source_lang zh, чужая книга) при английском пейлоаде и канонизировал бы вслепую. + brief = getattr(PR, pair().brief_key) msgs = [{"role": "system", "content": PR.render(core, brief, "", "")}, {"role": "user", "content": PR.render(user, brief, _payload(), "")}] led = MONEY.Guarded("Ф0", default_expect=0.02) @@ -123,7 +194,9 @@ def cmd_ask() -> None: cl = OpenAI(api_key=os.environ[env], base_url=base, timeout=600) kw = dict(model=TERM_MODEL, messages=msgs, max_completion_tokens=4000, extra_body={"reasoning_effort": "low"}) - rec = MONEY.purchase(led, "tp0-bank-terminolog", TERM_MODEL, cl, kw, role="terminologist") + # ⚠ Тег НЕСЁТ ПАРУ: `purchase` возвращает существующий файл как кэш, и пар-слепой тег + # молча отдал бы оплаченный КИТАЙСКИЙ ответ терминолога под видом английского, за $0. + rec = MONEY.purchase(led, _term_tag(), TERM_MODEL, cl, kw, role="terminologist") if rec.get("skipped"): print("⛔ потолок Ф0 — терминолог не куплен") return @@ -169,7 +242,7 @@ DROP = {"瞬间", "声音", "仿佛", "庭院"} def cmd_canon() -> None: - f = OUT / "tp0-bank-terminolog.json" + f = OUT / f"{_term_tag()}.json" if not f.exists(): print("нет ответа терминолога — сначала --ask") return @@ -178,7 +251,12 @@ def cmd_canon() -> None: for line in ans.splitlines(): if "\t" not in line: continue - src, dst = (x.strip() for x in line.split("\t", 1)) + # ⚠ ТРИ поля, не два: формат терминолога v3 — термин · перевод · уверенность 0–100. + # `split("\t", 1)` уносил хвост `\t95` в перевод и отравлял регекс канонной формы. + cols = [x.strip() for x in line.split("\t")] + if len(cols) < 2: + continue + src, dst = cols[0], cols[1] if src and dst and "TM-NO-DST" not in dst: got[src] = dst terms = {} @@ -192,8 +270,13 @@ def cmd_canon() -> None: terms[src] = dict(dst=dst, rx=_rx(dst), manual=bool(why), why=why) for src, (dst, why) in MANUAL.items(): terms.setdefault(src, dict(dst=dst, rx=_rx(dst), manual=True, why=why)) - PR.BANK_FILE.write_text(json.dumps( - dict(book=M.BOOK["title"], signed=False, + # ⚠ Пишем в банк ПАРЫ. Безусловная запись в zh-путь затирала бы китайский банк при сборке + # английского, и оба прогона переписывали бы друг друга. + P = pair() + out_file = PR.BANK_FILE if P.key == "zh-ru" else P.bank_file + out_file.parent.mkdir(parents=True, exist_ok=True) + out_file.write_text(json.dumps( + dict(book=M.BOOK["title"] if P.key == "zh-ru" else P.key, signed=False, note="ручной канон сессии, НЕ подпись владельца (образец D39.47)", model=TERM_MODEL, dropped=sorted(DROP), terms=terms), ensure_ascii=False, indent=1), encoding="utf-8") @@ -221,8 +304,11 @@ def coverage(source: str, out: str) -> tuple[int, int]: многословный выход набирал бы покрытие повторами. """ k = s = 0 - for t, v in PR.bank().items(): - n = source.count(t) + P = pair() + for t, v in PR.bank(P.bank_file if P.key != "zh-ru" else None).items(): + # ⚠ Не `source.count(t)`: на пробельной письменности подстрока считает `Dawn` внутри + # `Dawnbreaker` и теряет `dawn` со строчной. Знаменатель — по границе слова. + n = P.count_in_source(t, source) if n: s += n k += min(len(re.findall(v["rx"], out, re.I)), n) diff --git a/eval/tenant_panel/prompts.py b/eval/tenant_panel/prompts.py index 4bc5c747..94b906b5 100644 --- a/eval/tenant_panel/prompts.py +++ b/eval/tenant_panel/prompts.py @@ -28,6 +28,12 @@ import editor_wire_probe as P # noqa: E402 RT = REPO / "eval" / "role_topology" BATTLE = REPO / "backend" / "prompts" / "zh-ru" PAIR_EN = RT / "prompts" / "en-ru" +# ⚠ ПАРА — КЛЮЧ, А НЕ БУЛЕВО. `en: bool` умел ровно две пары и не имел пути для японского, при том +# что каталог `prompts/ja-ru/` существует и проходит гейт консистентности. Булево ветвление по паре +# в коде запрещено каноном проекта: пара обязана жить в данных. Старая сигнатура сохранена +# совместимой (`en=True` ≡ `pair="en-ru"`), чтобы не трогать вызовы паков 21–23. +PAIR_DIR = {"zh-ru": BATTLE, "en-ru": PAIR_EN, "ja-ru": RT / "prompts" / "ja-ru"} +PAIR_BRIEF = {"zh-ru": "BRIEF_ZH", "en-ru": "BRIEF_EN", "ja-ru": "BRIEF_JA"} USER_SEP = "\n---USER---\n" FEWSHOT_SEP = "\n---FEWSHOT---\n" BANK_FILE = Path.home() / "books" / "tenant-panel" / "bank.json" @@ -41,6 +47,16 @@ BRIEF_EN = {"source_lang": "en", "target_lang": "ru", "genre": "тёмное ф "venuti": "0.60", "honorifics": "keep", "transcription": "practical", "footnotes": "minimal"} +# ⚠ БРИФ ЯПОНСКОЙ ПАРЫ — его не существовало, хотя пар-промты `ja-ru` заведены и проходят гейт. +# Значения предложены приёмщиком другого модельного семейства при переписи пар-пакета и здесь +# зафиксированы как данные пары. `transcription: polivanov` — не косметика: от неё зависит, какую +# систему транскрипции промт называет переводчику. +BRIEF_JA = {"source_lang": "ja", "target_lang": "ru", "genre": "ранобэ (исэкай)", + "audience": "взрослые читатели ранобэ", + "title": "円環の果てに ―裏切られた魂、二度目の生―", + "venuti": "0.60", "honorifics": "keep", "transcription": "polivanov", + "footnotes": "minimal"} + GLOSSARY_HEADER = ("ГЛОССАРИЙ (используй эти утверждённые переводы имён и терминов последовательно; " "строки с пометкой ⟨проверить⟩ — неподтверждённые кандидаты):") @@ -75,14 +91,22 @@ def load_template(path: Path) -> tuple[str, str, str]: return head[0].strip(), (head[1].strip() if len(head) == 2 else ""), user.strip() -def bank() -> dict: +def bank(pair_file: Path | None = None) -> dict: """Банк среза: {термин исходника: {"dst": канон, "rx": регекс формы}}. Пусто — банка нет.""" - return json.loads(BANK_FILE.read_text(encoding="utf-8"))["terms"] if BANK_FILE.exists() else {} + # ⚠ ЯВНО ЗАПРОШЕННЫЙ банк пары обязан существовать. Тихий `{}` давал `coverage() = (0, 0)`, + # все вызыватели стоят за `if n:` — и канон-ось новой пары печаталась бы пустой без единого + # слова, а армы «перепис» и «перепис + канон-фиксер» стали бы неразличимы (близнец A4≡A0). + # Умолчание (pair_file=None) поведения zh не меняет. + if pair_file is not None and not pair_file.exists(): + raise SystemExit(f"⛔ банка пары нет: {pair_file} — собрать bank.py --ask/--canon " + "ДО покупок, иначе канон-ось пары молча пуста") + f = pair_file or BANK_FILE + return json.loads(f.read_text(encoding="utf-8"))["terms"] if f.exists() else {} -def terms_in(src: str) -> list[str]: +def terms_in(src: str, pair_file: Path | None = None) -> list[str]: """Термы банка, реально встреченные В ЭТОЙ единице — так инъектирует боевой путь (D39.104).""" - return [t for t in bank() if t in src] + return [t for t in bank(pair_file) if t in src] def glossary_block(terms: list[str]) -> str | None: @@ -93,17 +117,19 @@ def glossary_block(terms: list[str]) -> str | None: return GLOSSARY_HEADER + "\n" + "\n".join(f"{t} → {b[t]['dst']}" for t in terms) -def law_block(terms: list[str]) -> str | None: +def law_block(terms: list[str], pair_file: Path | None = None) -> str | None: """Закон-блок редактора: заголовок эксп-21 БЕЗ оговорки + строки `- src → «dst»`.""" - b = bank() + b = bank(pair_file) if not terms: return None return P.HEADER_LAW_PLAIN + "\n" + "\n".join(f"- {t} → «{b[t]['dst']}»" for t in terms) -def translator_msgs(src: str, block: str | None, en: bool = False) -> list[dict]: - core, _few, user = load_template(PAIR_EN / "translator.md" if en else BATTLE / "translator.md") - brief = BRIEF_EN if en else BRIEF_ZH +def translator_msgs(src: str, block: str | None, en: bool = False, + pair: str | None = None) -> list[dict]: + pair = pair or ("en-ru" if en else "zh-ru") + core, _few, user = load_template(PAIR_DIR[pair] / "translator.md") + brief = globals()[PAIR_BRIEF[pair]] m = [{"role": "system", "content": render(core, brief, src, "")}] if block: m.append({"role": "system", "content": block}) @@ -111,9 +137,11 @@ def translator_msgs(src: str, block: str | None, en: bool = False) -> list[dict] return m -def editor_msgs(src: str, draft: str, block: str | None, en: bool = False) -> list[dict]: - core, _few, user = load_template(PAIR_EN / "editor.md" if en else BATTLE / "editor.md") - brief = BRIEF_EN if en else BRIEF_ZH +def editor_msgs(src: str, draft: str, block: str | None, en: bool = False, + pair: str | None = None) -> list[dict]: + pair = pair or ("en-ru" if en else "zh-ru") + core, _few, user = load_template(PAIR_DIR[pair] / "editor.md") + brief = globals()[PAIR_BRIEF[pair]] m = [{"role": "system", "content": render(core, brief, src, draft)}] if block: m.append({"role": "system", "content": block})