textmachine/eval/dovodka/dreif.py

248 lines
16 KiB
Python
Raw 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
# -*- coding: utf-8 -*-
"""dreif.py — ДЕТЕРМИНИРОВАННЫЙ прибор дрейфа передач термина. Платных вызовов НЕ делает.
Заведён 02.09 по решению владельца (п.3 развилки фазы Д): «пишешь код на питоне, оркестратору
пинг про завести этот прибор». Настоящий прибор должен жить в движке (`tmctl recheck`) — этот
питон-двойник его НЕ заменяет, см. §«ЧЕГО ПРИБОР НЕ ДЕЛАЕТ».
ЧТО МЕРИТ. Для каждого ключа банка: сколько РАЗНЫХ русских передач встречается в переводе и
меняется ли доминирующая передача от главы к главе. Это цель владельца №1 («консистентные
термины на всю книгу»), и она измерима без судей и без денег.
⭐ ГЛАВНЫЙ УРОК ФАЗЫ, ВШИТЫЙ В ПРИБОР: **шум-пол считается ПЕРВЫМ и печатается рядом с каждым
числом.** Слепое чтение фазы Д дало «дешёвая рука лучше» (18:6), и вывод рухнул, когда появился
активный ноль: два прогона ОДНОЙ руки расходились сильнее (0.799), чем разные руки (0.820).
Поэтому здесь любая величина, измеренная МЕЖДУ руками, обязана печататься рядом с той же
величиной, измеренной ВНУТРИ одной руки (low₁ против low₂). Разница, не превышающая шум-пол,
объявляется НЕ ПРЕДЪЯВЛЕННОЙ — это не осторожность, это цена, уже заплаченная фазой.
ПОЧЕМУ ДРЕЙФ, А НЕ ПРОМАХИ. Пост-чек движка ищет ОТСУТСТВИЕ принятой формы и потому обязан знать
все падежи; без заполненных decl-форм наивное сопоставление даёт 1836% ложных тревог
(`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 stats(per_term, fired):
"""Две величины: разных передач на термин и сколько терминов меняют форму МЕЖДУ главами."""
multi = 0 # термин показал >1 передачи за отрезок
switched = 0 # доминирующая передача сменилась от главы к главе
measured = 0
for term, bych in per_term.items():
allv = set().union(*bych.values()) if bych else set()
if not allv:
continue
measured += 1
if len(allv) > 1:
multi += 1
doms = [sorted(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())