Add the deterministic drift instrument with the noise floor built in from the first line, plus a parser gate that refuses zero rather than printing no drift
This commit is contained in:
parent
880b8a6b3f
commit
7ccba1d59e
1 changed files with 248 additions and 0 deletions
248
eval/dovodka/dreif.py
Normal file
248
eval/dovodka/dreif.py
Normal file
|
|
@ -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"(?<![А-Яа-яЁё])" + 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())
|
||||
Loading…
Add table
Reference in a new issue