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:
heaven 2026-09-02 23:05:12 +03:00
parent 880b8a6b3f
commit 7ccba1d59e

248
eval/dovodka/dreif.py Normal file
View 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-форм наивное сопоставление даёт 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())