textmachine/eval/dovodka/canon.py

311 lines
21 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

#!/usr/bin/env python3
"""Д5 — КАНОН-ВСКРЫТИЕ: где именно боевой редактор теряет покрытие банка. $0.
Политика владельца 10.08, зафиксирована словом: «Банк существует, чтобы термины единообразно
переводились. Всё. Художественность текста — это отдельный вопрос.» ⇒ потеря канона = дефект
БЕЗУСЛОВНО, судейских опросов об «уместности замены» не бывает. Задача этого файла — не спорить
о том, дефект ли это, а дать фиксеру МЕХАНИЗМ: перечислить места потери и назвать класс каждой.
Замер идёт на купленном сырье эксп-22, денег не стоит и ничего не покупает.
ДВЕ РАЗНЫЕ ВЕЛИЧИНЫ, и путать их нельзя:
1. ПОКРЫТИЕ — доля канонной формы от числа упоминаний В ИСХОДНИКЕ (метрика `bank.coverage`).
Именно её печатали эксп-21/22. Она падает и когда термин передан ДРУГИМ переводом, и когда
он заменён местоимением — а второе есть норма русской прозы там, где китайская повторяет имя.
2. ЕДИНООБРАЗИЕ — то, ради чего банк существует по слову владельца: один термин передаётся
ОДНОЙ формой. Меряется подменой РОДОВОГО слова при неизменном якоре («дом Цинь» вместо
«род Цинь»), решается счётом по закрытому списку родовых и подпирается цитатой.
⚠ ЧЕГО ЗДЕСЬ БОЛЬШЕ НЕТ, И ПОЧЕМУ. Первая редакция классифицировала КАЖДОЕ место потери на
«исчез / парафраз / другой перевод» по выровненному окну редактуры. Адверсариальное ревью её
сняло, и правильно: (а) выравнивание не работает — окно НИ В ОДНОМ из 24 мест не содержало
канона, хотя в полном тексте редактуры он стоит 818 раз; (б) класс «парафраз» присваивался
регексом, который срабатывает на 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"(?<![\\"])([А-ЯЁA-Z][\w'-]{1,}(?:[- ][А-ЯЁA-Z][\w'-]{1,})?)")
EPITHET = re.compile(r"(?<!\w)(он|она|оно|они|его|её|их|тот|та|этот|эта|"
r"юнош\w+|девушк\w+|старик\w+|старейшин\w+|господин\w*|госпож\w+|"
r"молод\w+\s+господин\w*|наследник\w*|глав\w+|отец|мать|сын|дочь|"
r"человек\w*|мужчин\w+|женщин\w+|"
r"род\w*|семь\w+|клан\w*|техник\w+|искусств\w+|ступен\w+|уровн\w+|"
r"стади\w+|ядр\w+|фундамент\w*|пилюл\w+)(?!\w)", re.I)
def _near(a: str, b: str) -> 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"(?<![\w-])(" + "|".join(gen) + r")\w*\s+" + re.escape(anchor[:4]) + r"\w*",
re.I | re.U)
cnt: dict[str, int] = {}
places: dict[str, list[tuple[str, str]]] = {}
for u in units:
if u["uid"] in trunc:
continue
txt = ED.text_of("R0", u["uid"]) or ""
for m in rx.finditer(txt):
g = m.group(1).lower()
cnt[g] = cnt.get(g, 0) + 1
if not g.startswith(mod.lower()[:3]):
places.setdefault(g, []).append(
(u["uid"], txt[max(0, m.start() - 60):m.end() + 60].replace("\n", " ")))
if not cnt:
continue
tot = sum(cnt.values())
line = " · ".join(f"{g} {n}" for g, n in sorted(cnt.items(), key=lambda kv: -kv[1]))
print(f"\n {term} → канон «{canon}» (якорь «{anchor}»): {line}")
for g, ps in places.items():
share = cnt[g] / tot
viol.append((term, canon, g, cnt[g], share))
print(f" ⛔ КОНКУРИРУЮЩЕЕ РОДОВОЕ «{g} {anchor}» — {cnt[g]} из {tot} "
f"({share * 100:.0f}%)")
for uid, q in ps[:2]:
print(f" {uid}: …{q.strip()}")
if not seen_any:
print(" канонов вида «<родовое> <Имя>» в банке нет")
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())