311 lines
21 KiB
Python
311 lines
21 KiB
Python
#!/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"(?<![\w«\"])([А-ЯЁ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())
|