#!/usr/bin/env python3 """Д5 — КАНОН-ВСКРЫТИЕ: где именно боевой редактор теряет покрытие банка. $0. Политика владельца 10.08, зафиксирована словом: «Банк существует, чтобы термины единообразно переводились. Всё. Художественность текста — это отдельный вопрос.» ⇒ потеря канона = дефект БЕЗУСЛОВНО, судейских опросов об «уместности замены» не бывает. Задача этого файла — не спорить о том, дефект ли это, а дать фиксеру МЕХАНИЗМ: перечислить места потери и назвать класс каждой. Замер идёт на купленном сырье эксп-22, денег не стоит и ничего не покупает. ДВЕ РАЗНЫЕ ВЕЛИЧИНЫ, и путать их нельзя: 1. ПОКРЫТИЕ — доля канонной формы от числа упоминаний В ИСХОДНИКЕ (метрика `bank.coverage`). Именно её печатали эксп-21/22. Она падает и когда термин передан ДРУГИМ переводом, и когда он заменён местоимением — а второе есть норма русской прозы там, где китайская повторяет имя. 2. ЕДИНООБРАЗИЕ — то, ради чего банк существует по слову владельца: один термин передаётся ОДНОЙ формой. Меряется подменой РОДОВОГО слова при неизменном якоре («дом Цинь» вместо «род Цинь»), решается счётом по закрытому списку родовых и подпирается цитатой. ⚠ ЧЕГО ЗДЕСЬ БОЛЬШЕ НЕТ, И ПОЧЕМУ. Первая редакция классифицировала КАЖДОЕ место потери на «исчез / парафраз / другой перевод» по выровненному окну редактуры. Адверсариальное ревью её сняло, и правильно: (а) выравнивание не работает — окно НИ В ОДНОМ из 24 мест не содержало канона, хотя в полном тексте редактуры он стоит 8–18 раз; (б) класс «парафраз» присваивался регексом, который срабатывает на 84% ПРОИЗВОЛЬНЫХ окон того же текста, то есть наблюдённые 79% были НИЖЕ нулевой модели. Вывод «массовый класс — парафраз» на таком приборе не стоит, и он снят. Список мест остался как СЫРЬЁ для фиксера (где искать), но классом он больше не размечается и числом по классам не подводится. Запуск: eval/.venv/bin/python eval/dovodka/canon.py [--full] [--term <иероглиф>] """ from __future__ import annotations import difflib import importlib.util import json import re import sys from pathlib import Path REPO = Path("/home/ubuntu/projects/textmachine") OUT = Path.home() / "books" / "dovodka" for p in ("tenant_panel", "role_topology"): sys.path.insert(0, str(REPO / "eval" / p)) def _load(name: str, path: Path): spec = importlib.util.spec_from_file_location(name, path) mod = importlib.util.module_from_spec(spec) sys.modules[name] = mod spec.loader.exec_module(mod) return mod PAN = _load("panel22", REPO / "eval" / "tenant_panel" / "panel.py") BANK = _load("bank22", REPO / "eval" / "tenant_panel" / "bank.py") ED = _load("editors22", REPO / "eval" / "tenant_panel" / "editors.py") MAT = PAN.M # material.py эксп-22 SENT = re.compile(r"[^.!?…»\n]+[.!?…»]*\s*") # ⚠ ЗАКРЫТЫЙ СПИСОК РОДОВЫХ СЛОВ — данные КНИГИ, а не движка. Полигонный замер вправе знать # лексику своего материала (CLAUDE.md: «тест-данные пары легитимны»); в общий пар-слой это # не уходит. Классы заданы по канонам банка: то, чем в русском переводе называют 家 (клан/род). GENERICS = { "род": ("род", "дом", "семья", "семейство", "клан", "фамилия", "поместье"), } def GENERIC_CLASS(mod: str) -> tuple[str, ...]: # noqa: N802 for stem, cls in GENERICS.items(): if mod.lower().startswith(stem[:3]) or mod.lower() in cls: return cls return () def sents(t: str) -> list[str]: return [s for s in SENT.findall(t or "") if s.strip()] def best_match(s: str, edit_s: list[str], i: int, n_draft: int) -> int: """Индекс предложения редактуры, лучше всего отвечающего предложению черновика. ⚠ Первая редакция выравнивала `difflib`-опкодами по СПИСКУ предложений. На полном переписе это не работает: почти всё уходит в один опкод `replace` на пол-текста, и окно для всех мест единицы получалось одно и то же (поймано глазами при первом же прогоне — все «исчез» на единице `41599f30df` показывали одну и ту же реплику). Здесь ищется ближайшее по содержанию предложение в ПОЛОСЕ вокруг пропорциональной позиции: редактор порядок сохраняет, поэтому полоса законна, а сходство внутри неё решает. """ if not edit_s: return -1 c = int(i / max(1, n_draft) * len(edit_s)) band = max(4, int(0.15 * len(edit_s))) lo, hi = max(0, c - band), min(len(edit_s), c + band + 1) key = s.strip() best, bj = -1.0, c if c < len(edit_s) else len(edit_s) - 1 for j in range(lo, hi): r = difflib.SequenceMatcher(None, key, edit_s[j].strip(), autojunk=False).ratio() if r > best: best, bj = r, j return bj def window(edit_s: list[str], j: int) -> str: if j < 0: return "" return " ".join(edit_s[max(0, j - 1):min(len(edit_s), j + 2)]) # ⚠ Классификация ужесточена после ЧТЕНИЯ первых мест (мандат D39.61: вскрыть единицы до вывода). # Первая редакция звала «другим переводом» ЛЮБОЕ заглавное слово в окне — и записала в этот класс # `Этот человек`, `Кошмар`, `Перед такой`, то есть соседние слова предложения. Так класс «другой # перевод» набрал 14 мест из 24 и был бы вписан в отчёт неверным. Правило переписано: # `другой` — в окне стоит форма, ПОХОЖАЯ на канон (Цин Фэн / Цинь Фен / Qin Feng), но не он: # это и есть расхождение перевода термина, которое банк обязан устранять; # `парафраз` — на месте термина референциальная замена (местоимение, эпитет, родовое слово); # `исчез` — ни того, ни другого: место есть, термина и его следа нет. # ⚠ Второй элемент ОБЯЗАН начинаться с заглавной. Первая редакция клеила любое следующее слово, и # список «конкурирующих форм» наполнился мусором вида «Цинь в», «Фэн-лао до», «Тяньянчэне был» — # то есть склонениями и предлогами, а не переводами. Поймано чтением собственного вывода. TOKEN = re.compile(r"(? float: return difflib.SequenceMatcher(None, a.lower(), b.lower(), autojunk=False).ratio() def classify(win: str, rx: str, canon: str) -> tuple[str, str]: """(класс, улика). Улика печатается рядом с цитатой — вердикт проверяем чтением.""" if not win: return "исчез", "" if re.search(rx, win, re.I): return "есть", "" # похожая, но не канонная форма того же термина near = [(t, _near(t, canon)) for t in TOKEN.findall(win)] near = [(t, r) for t, r in near if 0.55 <= r < 1.0 and len(t) >= 3] if near: t, r = max(near, key=lambda x: x[1]) return "другой", f"{t} (сходство {r:.2f})" m = EPITHET.search(win) if m: return "парафраз", m.group(0) return "исчез", "" def main() -> int: full = "--full" in sys.argv only = sys.argv[sys.argv.index("--term") + 1] if "--term" in sys.argv else "" bank = BANK.PR.bank() units = MAT.units_zh() # словарь альтернативных написаний: латиница/иная транскрипция того же имени rows, per_term, per_class = [], {}, {} gross_loss = gross_gain = 0 tot_src = tot_hit_draft = tot_hit_edit = 0 # ⚠ Клетка R0 с `finish=length` даёт «потерю канона», которая на деле есть ОБРЫВ: термины # после места обрыва отсутствуют не потому, что редактор их убрал. Такие единицы считаются # отдельно (эксп-22 §15: класс замерен, на этой панели он покрывает единицу `41599f30df`). trunc = set() for u in units: f = PAN.OUT / f"{PAN.tag_edit(PAN.ANCHOR, u['uid'])}.json" if f.exists() and json.loads(f.read_text(encoding="utf-8")).get("finish") == "length": trunc.add(u["uid"]) for u in units: uid, src = u["uid"], u["source"] if uid in trunc: continue draft = PAN.draft_text(PAN.ANCHOR, uid) edit = ED.text_of("R0", uid) if hasattr(ED, "text_of") else PAN.edit_text(PAN.ANCHOR, uid) if not (draft and edit): continue ds, es = sents(draft), sents(edit) for term, v in bank.items(): if only and term != only: continue n = src.count(term) if not n: continue rx = v["rx"] hd = min(len(re.findall(rx, draft, re.I)), n) he = min(len(re.findall(rx, edit, re.I)), n) tot_src += n tot_hit_draft += hd tot_hit_edit += he if he > hd: gross_gain += he - hd # редактор канон ДОБАВИЛ if he >= hd: continue # редактор канон не терял gross_loss += hd - he # места: предложения черновика с канонной формой, чьё окно в редактуре его потеряло places = [] for i, s in enumerate(ds): if not re.search(rx, s, re.I): continue j = best_match(s, es, i, len(ds)) win = window(es, j) cls, why = classify(win, rx, v["dst"]) if cls == "есть": continue places.append((cls, why, s.strip()[:150], win.strip()[:190])) keep = places[: (hd - he)] or places for cls, why, s, w in keep: rows.append((uid, term, v["dst"], cls, why, s, w)) per_class[cls] = per_class.get(cls, 0) + 1 per_term.setdefault(term, {"dst": v["dst"], "n": 0}) per_term[term]["n"] += 1 print("Д5 — КАНОН-ВСКРЫТИЕ БОЕВОГО РЕДАКТОРА (R0 поверх якорного черновика, эксп-22, $0)\n") print(f"единиц {len(units)} · из них ИСКЛЮЧЕНО по обрыву клетки R0: {len(trunc)} " f"{sorted(trunc)} · разбирается {len(units) - len(trunc)}") print(f"терминов в банке {len(bank)} · упоминаний в исходниках разбираемых единиц {tot_src}") print(f"покрытие ЧЕРНОВИКА {tot_hit_draft}/{tot_src} = {tot_hit_draft / max(1, tot_src):.3f}" f" · покрытие РЕДАКТУРЫ {tot_hit_edit}/{tot_src} = {tot_hit_edit / max(1, tot_src):.3f}" f" · САЛЬДО {tot_hit_draft - tot_hit_edit}") print(f"⚠ САЛЬДО — не потеря. ВАЛОВАЯ потеря {gross_loss} упоминаний, ВАЛОВОЙ прирост " f"{gross_gain}: в части пар редактор канон ДОБАВИЛ там, где черновик его не держал.\n" " Прежняя редакция печатала одно число «ПОТЕРЯ» рядом с числом мест и читалась так,\n" " будто это одно и то же (поймано ревью). Набор фиксера ниже покрывает валовую потерю,\n" " а не сальдо.") print(f"\nмест, где канонная форма отсутствует в выровненном окне: {len(rows)}") print(" ⚠ КЛАССАМИ НЕ РАЗМЕЧАЮТСЯ. Прежняя разбивка «исчез/парафраз/другой» снята ревью:\n" " выравнивание ненадёжно, а класс «парафраз» слабее нулевой модели. Ниже — сырьё\n" " для фиксера (где смотреть), а не измерение долей.") print("\nПО ТЕРМИНАМ (что фиксер обязан уметь возвращать):") print(f"{'термин':10s}{'канон':30s}{'мест потери':>12s}") for t, d in sorted(per_term.items(), key=lambda kv: -kv[1]["n"]): print(f"{t:10s}{d['dst']:30s}{d['n']:12d}") print("\nМЕСТА (цитатой; окно приблизительное — сверять глазами, это подсказка, не вердикт):") for uid, term, dst, _cls, _why, s, w in (rows if full else rows[:12]): print(f"\n {uid} · {term} → «{dst}»") print(f" черновик: …{s}…") print(f" редактура: …{w}…") if not full and len(rows) > 12: print(f"\n … ещё {len(rows) - 12} мест, целиком — с флагом --full") # ── ВТОРАЯ МЕТРИКА: ЕДИНООБРАЗИЕ, а не частота ───────────────────────────────────────────── # ⚠ ПЕРВАЯ РЕДАКЦИЯ ЭТОЙ МЕТРИКИ НЕ РАБОТАЛА И БЫЛА СНЯТА АДВЕРСАРИАЛЬНЫМ РЕВЬЮ. Она искала # «похожие на канон строки» по расстоянию Левенштейна и набирала склонения («Цао Ина») и чужих # персонажей с общей фамилией («Цинь Вэньтянь»); настоящие нарушения — «дом Цинь» вместо «род # Цинь» — не находила вовсе, потому что требовала заглавной буквы у первого слова. Ревьюер # нашёл их РУКАМИ, и это и есть доказательство, что счётом их взять можно: они отличаются # РОДОВЫМ СЛОВОМ при том же якоре. # # Правило теперь такое: у канона вида «<родовое> <Имя>» якорь — имя, а родовое слово меняется. # Считаем распределение родовых слов при якоре ПО ЗАКРЫТОМУ СПИСКУ и зовём нарушением всякое, # чей корень отличается от канонного. Список — данные КНИГИ (полигонный замер, не движок). print("\n" + "═" * 78) print("ЕДИНООБРАЗИЕ: каким РОДОВЫМ словом передан термин при том же якоре") viol, seen_any = [], False for term, v in bank.items(): canon = v["dst"] toks = canon.split() if len(toks) != 2: continue mod, anchor = toks gen = GENERIC_CLASS(mod) if not gen: continue seen_any = True rx = re.compile(r"(? <Имя>» в банке нет") print(f"\nНАРУШЕНИЙ ЕДИНООБРАЗИЯ (конкурирующее родовое при том же якоре): {len(viol)}") print("⚠ ЧТО ЭТА МЕТРИКА ЛОВИТ И ЧЕГО НЕ ЛОВИТ. Ловит подмену РОДОВОГО слова при неизменном\n" " якоре — класс, ради которого банк и существует по слову владельца. НЕ ловит: подмену\n" " самого якоря (транскрипция имени), расхождения у одно-словных канонов и у канонов\n" " вида «прилагательное + существительное» (金丹 «Золотое ядро» → «Золотая пилюля»:\n" " найдено ЧТЕНИЕМ на единице `c73f4857e7`, счётом здесь не берётся). Список родовых —\n" " закрытый и книго-специфичный; его полнота и есть граница метрики.") OUT.mkdir(parents=True, exist_ok=True) (OUT / "canon-dissect.json").write_text(json.dumps( [dict(uid=a, term=b, dst=c, cls=d, why=e, draft=f, edit=g) for a, b, c, d, e, f, g in rows], ensure_ascii=False, indent=1), encoding="utf-8") print(f"\nперсист → {OUT / 'canon-dissect.json'}") return 0 if __name__ == "__main__": sys.exit(main())