diff --git a/eval/dovodka/dreif.py b/eval/dovodka/dreif.py new file mode 100644 index 00000000..3319a952 --- /dev/null +++ b/eval/dovodka/dreif.py @@ -0,0 +1,248 @@ +#!/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"(?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())