265 lines
17 KiB
Python
265 lines
17 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""dreif.py — ДЕТЕРМИНИРОВАННЫЙ прибор дрейфа передач термина. Платных вызовов НЕ делает.
|
||
|
||
Заведён 02.09 по решению владельца (п.3 развилки фазы Д): «пишешь код на питоне, оркестратору
|
||
пинг про завести этот прибор». Настоящий прибор должен жить в движке (`tmctl recheck`) — этот
|
||
питон-двойник его НЕ заменяет, см. §«ЧЕГО ПРИБОР НЕ ДЕЛАЕТ».
|
||
|
||
ЧТО МЕРИТ. Для каждого ключа банка: сколько РАЗНЫХ русских передач встречается в переводе и
|
||
меняется ли доминирующая передача от главы к главе. Это цель владельца №1 («консистентные
|
||
термины на всю книгу»), и она измерима без судей и без денег.
|
||
|
||
⭐ ГЛАВНЫЙ УРОК ФАЗЫ, ВШИТЫЙ В ПРИБОР: **шум-пол считается ПЕРВЫМ и печатается рядом с каждым
|
||
числом.** Слепое чтение фазы Д дало «дешёвая рука лучше» (18:6), и вывод рухнул, когда появился
|
||
активный ноль: два прогона ОДНОЙ руки расходились сильнее (0.799), чем разные руки (0.820).
|
||
Поэтому здесь любая величина, измеренная МЕЖДУ руками, обязана печататься рядом с той же
|
||
величиной, измеренной ВНУТРИ одной руки (low₁ против low₂). Разница, не превышающая шум-пол,
|
||
объявляется НЕ ПРЕДЪЯВЛЕННОЙ — это не осторожность, это цена, уже заплаченная фазой.
|
||
|
||
ПОЧЕМУ ДРЕЙФ, А НЕ ПРОМАХИ. Пост-чек движка ищет ОТСУТСТВИЕ принятой формы и потому обязан знать
|
||
все падежи; без заполненных decl-форм наивное сопоставление даёт 18–36% ложных тревог
|
||
(`membank/mempostcheck.go:24-27`, замерено проектом). Дрейф так не ломается: «юаньши» против
|
||
«изначальный камень» — разные ЛЕКСЕМЫ, а не падежи одной.
|
||
"""
|
||
|
||
import argparse
|
||
import json
|
||
import os
|
||
import re
|
||
import subprocess
|
||
import sys
|
||
import sqlite3
|
||
from collections import Counter, defaultdict
|
||
|
||
B = os.path.expanduser("~/books/gu-zhenren/probe-4axes")
|
||
TMCTL = f"{B}/bin/tmctl"
|
||
# low2 — вторая реплика ТОЙ ЖЕ конфигурации, что p9-low. Пара (p9-low, low2) — шум-пол.
|
||
ARMS = ["p7-off", "p7-low", "p9-off", "p9-low", "low2"]
|
||
NOISE_PAIR = ("p9-low", "low2")
|
||
|
||
|
||
def pairs(arm):
|
||
"""export --pairs: исходник рядом с переводом. $0, читает уже оплаченное."""
|
||
cfg = f"{B}/arms/{arm}.book.yaml"
|
||
out = subprocess.run([TMCTL, "export", "--config", cfg, "--pairs"],
|
||
capture_output=True, text=True)
|
||
if out.returncode != 0:
|
||
sys.exit(f"⛔ export отказал для {arm}: {out.stderr[:300]}")
|
||
d = json.loads(out.stdout)
|
||
return {(c["chapter"], c["chunk_idx"]): c for c in d["chunks"]}
|
||
|
||
|
||
def bank(arm):
|
||
"""Ключи банка и все передачи, которые для них вообще предлагались."""
|
||
con = sqlite3.connect(f"file:{B}/arms/{arm}.db?mode=ro", uri=True)
|
||
rows = con.execute("select src, dst, type, status from glossary where src!=''").fetchall()
|
||
out = {}
|
||
for src, dst, typ, status in rows:
|
||
out[src] = {"dst": dst, "type": typ, "status": status, "variants": set()}
|
||
# bank-stop.txt печатает, какие передачи переводчик предлагал для ключа — это сырьё дрейфа.
|
||
# ⚠ ФОРМАТ СНЯТ С ФАЙЛА, НЕ УГАДАН: заголовок записи — «<ключ>\t<передача>» БЕЗ ведущих
|
||
# пробелов, поля записи — с отступом в два пробела. Первая редакция искала «src: <ключ>»,
|
||
# не совпадала НИ РАЗУ и оттого печатала «дрейфа 0%» на пустых вариантах — ровно тот отказ,
|
||
# за который фаза уже платила (Д50: вывод на нулевом знаменателе). Отсюда гейт ниже.
|
||
stop = f"{B}/arms/{arm}.db.bank-stop.txt"
|
||
parsed = 0
|
||
if os.path.exists(stop):
|
||
cur = None
|
||
for line in open(stop, encoding="utf-8"):
|
||
if line.strip() and not line.startswith((" ", "\t")) and "\t" in line:
|
||
cur = line.split("\t", 1)[0].strip()
|
||
continue
|
||
m = re.match(r"^\s+drafts:\s*(.+)$", line)
|
||
if m and cur and cur in out:
|
||
for part in m.group(1).split("|"):
|
||
v = re.sub(r"\s*×\d+\s*$", "", part.strip()).strip()
|
||
if v:
|
||
out[cur]["variants"].add(v)
|
||
parsed += 1
|
||
# spread — НЕЗАВИСИМЫЙ путь к тому же числу: движок сам посчитал разброс передач
|
||
m = re.match(r"^\s+origin=.*?\bspread=(\d+)", line)
|
||
if m and cur and cur in out:
|
||
out[cur]["spread"] = int(m.group(1))
|
||
# ГЕЙТ ПАРСЕРА: банк заведомо непуст, значит ноль разобранных передач = сломанный формат
|
||
if os.path.exists(stop) and parsed == 0:
|
||
sys.exit(f"⛔ {arm}: из bank-stop.txt не разобрано НИ ОДНОЙ передачи — формат не тот, "
|
||
f"что ждёт парсер. Это отказ прибора, а не отсутствие дрейфа.")
|
||
return out
|
||
|
||
|
||
def observed(term_variants, ru_text):
|
||
"""Какие из известных передач реально видны в переводе. Возвращает Counter."""
|
||
seen = Counter()
|
||
for v in term_variants:
|
||
if len(v) < 3:
|
||
continue # односимвольные/двухбуквенные — шум сопоставления
|
||
# ⚠ сопоставление по НАЧАЛУ слова: русский падёж меняет хвост, а не начало.
|
||
# Это ослабленная замена decl-форм, которых в банке нет НИ У ОДНОГО термина
|
||
# (все 102 записи status=draft/auto, decl пуст) — оговорено в §«ЧЕГО НЕ ДЕЛАЕТ».
|
||
stem = v[: max(4, len(v) - 2)]
|
||
if re.search(r"(?<![А-Яа-яЁё])" + re.escape(stem), ru_text, re.IGNORECASE):
|
||
seen[v] += 1
|
||
return seen
|
||
|
||
|
||
def measure(arm):
|
||
"""Дрейф по одной руке: {термин: {главы: {передача}}}."""
|
||
px, bk = pairs(arm), bank(arm)
|
||
per_term = defaultdict(lambda: defaultdict(set))
|
||
fired = Counter()
|
||
for (ch, ci), c in px.items():
|
||
src_text, ru_text = c.get("source", ""), c.get("final_text", "")
|
||
if not src_text or not ru_text:
|
||
continue
|
||
for term, meta in bk.items():
|
||
if term not in src_text:
|
||
continue # ключ в этом куске не встретился — термин не «выстрелил»
|
||
fired[term] += 1
|
||
vs = set(meta["variants"]) | ({meta["dst"]} if meta["dst"] else set())
|
||
for v in observed(vs, ru_text):
|
||
per_term[term][ch].add(v)
|
||
return per_term, fired, bk
|
||
|
||
|
||
def collapse(variants):
|
||
"""Схлопнуть варианты, где один — лишь укороченная форма другого.
|
||
|
||
⚠ ЗАМЕРЕННАЯ ЛОЖНАЯ ТРЕВОГА, а не предосторожность. Сопоставление идёт по НАЧАЛУ слова
|
||
(падеж меняет хвост), и потому основа короткого варианта находится ВНУТРИ длинного:
|
||
· «Гао Ван» (основа «Гао В») находится в «Гао Вань» — в тексте 82 вхождения ТОЛЬКО
|
||
«Гао Вань/Ваня/Ванем», «Гао Ван» отдельно не встречается ни разу;
|
||
· «женщина-гу蛊师» (основа «женщина-гу») находится в «женщина-гу-мастер».
|
||
Обе пары прибор объявлял дрейфом: 2 ложные тревоги из 12 (17%) на руке `p9-low`.
|
||
"""
|
||
out = []
|
||
for v in sorted(variants, key=len, reverse=True):
|
||
if not any(longer.lower().startswith(v.lower()[: max(4, len(v) - 2)]) for longer in out):
|
||
out.append(v)
|
||
return set(out)
|
||
|
||
|
||
def stats(per_term, fired):
|
||
"""Две величины: разных передач на термин и сколько терминов меняют форму МЕЖДУ главами."""
|
||
multi = 0 # термин показал >1 передачи за отрезок
|
||
switched = 0 # доминирующая передача сменилась от главы к главе
|
||
measured = 0
|
||
for term, bych in per_term.items():
|
||
allv = collapse(set().union(*bych.values())) if bych else set()
|
||
if not allv:
|
||
continue
|
||
measured += 1
|
||
if len(allv) > 1:
|
||
multi += 1
|
||
doms = [sorted(collapse(v))[0] for ch, v in sorted(bych.items()) if v]
|
||
if len(set(doms)) > 1:
|
||
switched += 1
|
||
return {"terms_fired": len([t for t in fired if fired[t]]), "measured": measured,
|
||
"multi": multi, "switched": switched}
|
||
|
||
|
||
def spread_stats(bk):
|
||
"""НЕЗАВИСИМЫЙ путь: разброс, посчитанный САМИМ движком (поле spread в bank-stop).
|
||
Норма приёмки — число двумя независимыми путями; здесь второй путь не зависит ни от
|
||
моего сопоставления, ни от падежей."""
|
||
sp = [m.get("spread", 0) for m in bk.values() if "spread" in m]
|
||
multi = sum(1 for v in sp if v > 1)
|
||
return {"with_spread": len(sp), "multi_spread": multi,
|
||
"max_spread": max(sp) if sp else 0}
|
||
|
||
|
||
def main():
|
||
ap = argparse.ArgumentParser(description="детерминированный дрейф передач, $0")
|
||
ap.add_argument("--selftest", action="store_true")
|
||
a = ap.parse_args()
|
||
if a.selftest:
|
||
return selftest()
|
||
|
||
print("ПРИБОР ДРЕЙФА — детерминированный, платных вызовов нет\n")
|
||
res = {}
|
||
sp_res = {}
|
||
for arm in ARMS:
|
||
pt, fired, bk = measure(arm)
|
||
res[arm] = stats(pt, fired)
|
||
sp_res[arm] = spread_stats(bk)
|
||
s = res[arm]
|
||
# ГЕЙТ (урок Д50): нулевой знаменатель — ОТКАЗ, а не «дрейфа нет»
|
||
if s["measured"] == 0:
|
||
sys.exit(f"⛔ {arm}: измерено 0 терминов — прибор НЕ ИЗМЕРИЛ, число печатать нельзя")
|
||
print(f" {arm:8} терминов выстрелило {s['terms_fired']:3}, измерено {s['measured']:3} · "
|
||
f"с >1 передачей {s['multi']:3} ({s['multi']/s['measured']:.0%}) · "
|
||
f"сменили форму между главами {s['switched']:3} ({s['switched']/s['measured']:.0%})")
|
||
|
||
print("\n НЕЗАВИСИМЫЙ путь к тому же — разброс, посчитанный САМИМ движком (поле spread):")
|
||
for arm in ARMS:
|
||
q = sp_res[arm]
|
||
print(f" {arm:8} записей со spread {q['with_spread']:3} · из них >1 передачи "
|
||
f"{q['multi_spread']:3} ({q['multi_spread']/max(1,q['with_spread']):.0%}) · "
|
||
f"максимум передач у одного ключа {q['max_spread']}")
|
||
|
||
n1, n2 = NOISE_PAIR
|
||
print("\n" + "=" * 70)
|
||
print("ШУМ-ПОЛ ПЕРВЫМ (иначе число между руками читать нельзя)")
|
||
print("=" * 70)
|
||
floor_multi = abs(res[n1]["multi"] / res[n1]["measured"] - res[n2]["multi"] / res[n2]["measured"])
|
||
floor_sw = abs(res[n1]["switched"] / res[n1]["measured"] - res[n2]["switched"] / res[n2]["measured"])
|
||
print(f" {n1} против {n2} — ОДНА конфигурация, два розыгрыша:")
|
||
print(f" расхождение по «>1 передачи»: {floor_multi:.1%}")
|
||
print(f" расхождение по «смене между глав»: {floor_sw:.1%}")
|
||
|
||
# ⚠ Шум-пол снят по ОДНОЙ паре реплик: это точечная оценка, а не распределение.
|
||
# Ноль по одной оси НЕ означает «шума нет» — он означает «на одном розыгрыше не проявился».
|
||
# Поэтому порогом берём КОНСЕРВАТИВНУЮ величину: наибольшую из шумовых оценок. Иначе ось,
|
||
# где шум случайно вышел 0.0%, объявляла бы значимым любое ненулевое различие — ровно та
|
||
# ловушка, в которую фаза уже попала со счётом 18:6.
|
||
floor = max(floor_multi, floor_sw)
|
||
print(f" ⇒ консервативный порог (наибольшая из оценок, одна пара реплик): {floor:.1%}")
|
||
|
||
print("\n между РЕЖИМАМИ (то же на разных руках):")
|
||
for hi, lo in (("p7-off", "p7-low"), ("p9-off", "p9-low")):
|
||
d_multi = abs(res[hi]["multi"] / res[hi]["measured"] - res[lo]["multi"] / res[lo]["measured"])
|
||
d_sw = abs(res[hi]["switched"] / res[hi]["measured"] - res[lo]["switched"] / res[lo]["measured"])
|
||
for name, d in (("«>1 передачи»", d_multi), ("«смена между глав»", d_sw)):
|
||
v = "ВЫШЕ порога" if d > floor else "НЕ ПРЕДЪЯВЛЕНО (в пределах шума)"
|
||
print(f" {hi} против {lo}: {name:20} {d:.1%} → {v}")
|
||
|
||
print("\n" + "=" * 70)
|
||
print("ЧЕГО ПРИБОР НЕ ДЕЛАЕТ (норма: гейт обязан печатать, чего он НЕ проверяет)")
|
||
print("=" * 70)
|
||
print(""" · НЕ пост-чек движка. Тот ищет ОТСУТСТВИЕ принятой формы и требует заполненных decl-форм.
|
||
В этом банке их нет НИ У ОДНОГО из 102 терминов (все status=draft/auto, decl пуст), и ни один
|
||
не доведён до approved — поэтому штатный детектор в пробе молчал не только из-за выключенного
|
||
гейта, но и потому, что ВХОДА у него не было.
|
||
· НЕ измеряет частоту ложных тревог пост-чека — мерить не на чем, пока банк не подписан.
|
||
· НЕ ловит падежи как движок: здесь сопоставление по началу слова, а не по сохранённым формам.
|
||
Значит редкая передача, отличающаяся только началом, может быть пропущена.
|
||
· НЕ судит КАЧЕСТВО передачи: «изначальный камень» может быть лучше «юаньши» — прибор лишь
|
||
считает, что их две.
|
||
· НЕ переносится на книгу: 17 глав = 0.74%. Дрейф накопителен, и короткий отрезок его занижает.""")
|
||
|
||
|
||
def selftest():
|
||
print("СЕЛФТЕСТ dreif.py")
|
||
ok = True
|
||
|
||
def check(name, cond):
|
||
nonlocal ok
|
||
print((" ✓ " if cond else " ✗ ") + name)
|
||
ok = ok and cond
|
||
|
||
check("движок на месте", os.path.exists(TMCTL))
|
||
for arm in ARMS:
|
||
check(f"конфиг руки {arm}", os.path.exists(f"{B}/arms/{arm}.book.yaml"))
|
||
# сопоставление: падежная форма ловится, чужое слово — нет
|
||
c = observed({"юаньши"}, "он отдал пятьсот юаньши за это")
|
||
check("падежная форма ловится", bool(c))
|
||
c2 = observed({"юаньши"}, "он отдал пятьсот монет за это")
|
||
check("чужой текст НЕ даёт ложного попадания", not c2)
|
||
c3 = observed({"изначальный камень"}, "поднял изначальный камень")
|
||
check("многословная передача ловится", bool(c3))
|
||
print("\n" + ("ПРИБОР ГОДЕН" if ok else "⛔ ПРИБОР НЕ ГОДЕН"))
|
||
return 0 if ok else 1
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|