textmachine/eval/dovodka/bank.py

397 lines
27 KiB
Python
Raw Permalink 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 -*-
"""
bank.py — АУДИТ УДЕРЖАНИЯ БАНКА на движковых прогонах 蛊真人 (zh→ru, 25 глав).
Стадия 0 п.1 плана `eval/dovodka/KONSILIUM-30-08.md` §5. Стоимость $0: только артефакты на диске.
═══════════════════════════════════════════════════════════════════════════════════════════════
⚠⚠ ПОПРАВКА КУРСА, НАЙДЕННАЯ ИСПОЛНЕНИЕМ 30.08 — ЧИТАТЬ ПРЕЖДЕ ЧИСЕЛ
═══════════════════════════════════════════════════════════════════════════════════════════════
Консилиум назначил аудит прогона `acceptance`, НЕ ЗНАЯ ДВУХ ВЕЩЕЙ:
1. ⭐ РЯДОМ ЛЕЖИТ БОЛЕЕ ПОЗДНИЙ И БОЛЕЕ БОЕВОЙ ПРОГОН `rerun` (11.07, `~/books/gu-zhenren/rerun/`).
Он собран из боевого `pipeline-c1.yaml` post-D33 байт-в-байт: промты `v1-reflow` (не `v0-draft`),
редактор БИЛИНГВ (D30.1), переведены все 57 чанков (у `acceptance` — 54 из 57). Движковых
промахов там 27 против 55. ⇒ ОБЪЕКТ АУДИТА — ОБА, и `rerun` важнее.
⚠ Боевым он всё равно НЕ является: редактор `glm-5`, а не `deepseek-v4-pro`.
2. ⛔ ЭТУ РАБОТУ УЖЕ ДЕЛАЛИ В ИЮЛЕ, и результат сильнее плановой заявки. `docs/archive/
PROGRESS-2026-07-10-13.md:149` (замер B5 D10, независимый скрипт `rerun/d10_consistency.py`):
presence 91.0% (647/711) · occurrence 95.8% (3072/3207)
по типам: имена 98.6% · клички 100% · места 96.0% · термины 94.9% · ТИТУЛЫ 78.7%
промахи: decl_gap 0 · inflection_gap 54 · genuine_absent 10 ⇒ эффективная 98.6%
⛔ Сами СКРИПТЫ не пережили переезд машины — лежали ВНЕ git (`PROGRESS-2026-07-10-13.md:190`
перечисляет их поимённо; в каталоге остались только данные). Это ЧЕТВЁРТАЯ тихая поломка
переезда после путей `~/books`, обнулённых mtime и подчёркиваний в `.env`. Класс один:
ЧТО ВНЕ GIT, ТО ПЕРЕЕЗД УБИВАЕТ МОЛЧА. Этот файл — в git намеренно.
⇒ Июльские числа здесь не пересказываются как свои: они ЭТАЛОН СВЕРКИ. Совпадение = две
независимые реализации через полтора месяца согласились. Расхождение = находка, а не шум.
═══════════════════════════════════════════════════════════════════════════════════════════════
ПРЕ-РЕГИСТРАЦИЯ — ПРАВИЛО РЕШЕНИЯ ЗАПИСАНО ДО ПЕРВОГО ЧИСЛА
═══════════════════════════════════════════════════════════════════════════════════════════════
ЧТО МЕРЯЕМ. Вопрос 0 в его ТЕРМИННОЙ половине: удерживает ли банк канонные формы через 25 глав.
Голосовая половина им НЕ закрывается ПО ПОСТРОЕНИЮ — колонки `first_person`/`speech` пусты 0/49
(Д32.16, консилиум §5 п.1).
ЕДИНИЦА — пара (термин, чанк), в которой ключ термина СРАБОТАЛ в китайском оригинале чанка. Ровно
это условие включает инжекцию записи в промт, поэтому только такие пары и подсудны.
ДИСПОЗИЦИИ (`backend/internal/membank/memory.go:121,650-663` — прочитано, не пересказано):
approved → CONFIRMED (авторитетно) · auto/draft → AMBIGUOUS (кандидат, обязательности нет).
Блок ограничений редактора — CONFIRMED-only (`memory.go:745-750`).
⇒ ОСНОВНОЙ ЭНДПОЙНТ — CONFIRMED. С AMBIGUOUS-записи спрашивать соблюдения нельзя: движок её
таковой не объявлял, и `mempostcheck.go:52-64` прямо зовёт наказание за неё инверсией контракта.
ПРИНЯТОЕ МНОЖЕСТВО ФОРМ = базовый `dst` `decl.forms`. Это РАТИФИЦИРОВАННОЕ D24.4, а не мой
выбор: проверка одних косвенных давала 37 ложных промахов из 55 на этих же данных (nominative_gap).
ТРИ СТРУКТУРНЫХ ИСКЛЮЧЕНИЯ — каждое установлено кодом или данными, ни одно не «для красоты»:
1. ЧАНКИ БЕЗ ПЕРЕВОДА. Гейт покрытия пометил их флагом и не переводил вовсе; в параллельном
файле у них нет секции `[ПЕРЕВОД]`. Отсутствие термина там — дефект ПОКРЫТИЯ, не банка.
⚠ Печатается отдельной строкой, а не заминается.
2. ОДНОСИМВОЛЬНЫЕ ХАНЬСКИЕ КЛЮЧИ без `allow_short` (蛊, 转). `memory.go:319` УДАЛЯЕТ такой ключ
из автомата — записи не инжектировались НИКОГДА, спрашивать за них нельзя. (В живом коде есть
ещё и демот таких промахов, `mempostcheck.go:122`, но он от D39.39 — ПОЗЖЕ этих прогонов.)
3. STICKY (scene-inertia, A5) — ключа в чанке нет по построению, `mempostcheck.go:31-33`
исключает их сознательно. Тоже печатается числом.
ПРАВИЛО РЕШЕНИЯ (порог консилиума §5 п.1; двусмысленность его второй половины разрешена ЗДЕСЬ,
до чисел, и разрешение объявлено явно):
A. взвешенная доля промахов CONFIRMED > 2% ⇒ «банк держит» ложна в сильной форме;
B. «любая сущность топ-50» двусмысленна — записей всего 49, и буквальное чтение убивает гипотезу
одной случайной строкой. Читаю как проверку на СИСТЕМАТИЧЕСКИЙ провал ядра: термин из ТОП-10
по весу ключа с долей промахнутых чанков >= 20%.
Обе печатаются всегда и не пересматриваются после просмотра чисел.
КЛАССИФИКАЦИЯ ПРОМАХА (июльская номенклатура, чтобы числа были сравнимы):
decl_gap — принятая форма есть, но её не увидели бы БЕЗ union D24.4 (контроль: должен быть 0);
inflection_gap — основа dst присутствует в форме, которой в сиде НЕТ (обычно мн.ч.) ⇒ читатель
видит верный термин, это дефект ЗАСЕВА, а не дрейф;
genuine_absent — основы нет вовсе ⇒ КАНДИДАТ дрейфа, требующий чтения глазами. НЕ дрейф сам по себе.
⚠ Граница признана честно: определитель основы здесь — ЭВРИСТИКА общего префикса, а не морфология
(в движке стеммер настоящий). Поэтому `genuine_absent` объявляется КАНДИДАТОМ и подлежит чтению.
ДВА РЕЖИМА МАТЧИНГА, потому что эталоны построены по-разному:
native — longest-match, как автомат движка (古月 внутри 古月漠北 не считается);
substring — независимая подстрока на термин, как июльский скрипт: он этим и обошёл alias-слепое
пятно D24.2, поймав 古月→Гуюэ ×2. Числа июля сравнивать нужно с ЭТИМ режимом.
ВТОРОЙ КОНТУРСВЕРКА С ДВИЖКОВЫМ ПОСТ-ЧЕКОМ (`postcheck_gate: false` ⇒ флаггер пишет промахи в
`retrieval_state`). Расхождение ЛЮБОГО знака — находка:
движок нашёл, я нет ⇒ ложная тревога гейта (ожидаемо: базы писаны ДО фикса D24.4);
я нашёл, движок нет ⇒ слепота гейта — то, чего он не смотрит.
ЧЕГО ПРИБОР НЕ УМЕЕТ: он не читает смысл — термин в правильной форме не на своём месте он засчитает.
"""
import argparse
import json
import re
import sqlite3
import sys
from collections import defaultdict
from pathlib import Path
RUNS = {
"acceptance": {
"db": Path.home() / "books/gu-zhenren/acceptance/guzhenren-acc-a.db",
"parallel": Path.home() / "books/gu-zhenren/acceptance/guzhenren-25ch-parallel.txt",
"book_id": "guzhenren-acc-a",
"note": "10.07, промты v0-draft, редактор glm-5 монолингв, 54/57 чанков переведены",
},
"rerun": {
"db": Path.home() / "books/gu-zhenren/rerun/guzhenren-rerun.db",
"parallel": Path.home() / "books/gu-zhenren/rerun/rerun-parallel.txt",
"book_id": None, # берётся из базы: имя книги у пере-прогона своё
"note": "11.07, промты v1-reflow, редактор glm-5 БИЛИНГВ, 57/57 чанков — ближе к боевому",
},
}
WEIGHTED_MISS_MAX = 0.02
TOP_N = 10
CORE_MISS_MAX = 0.20
# Эвристика основы для КЛАССИФИКАЦИИ промаха (на сам вердикт не влияет — метрики A и B считают
# промахи, а не классы, поэтому это число ничего подогнать не может).
# ⚠ 4 знака ЛОМАЮТСЯ НА ЧЕРЕДОВАНИИ СОГЛАСНЫХ: «очистить»[:4] = «очис», а в тексте стоит «очищает» —
# и верно переведённый термин уезжает в `genuine_absent`, то есть в кандидаты дрейфа. Поймано
# чтением текста 30.08 на 炼化 → «очистить и подчинить»: все 7 его «промахов» суть верный перевод
# при ПУСТОМ `decl.forms`. 3 знака чередование переживают; цена — более грубое сито.
STEM_MIN = 3
CHAPTER_RE = re.compile(r"^【 Глава (\d+) 】", re.M)
# ⚠ Флаг в скобках есть у `acceptance` и ОТСУТСТВУЕТ у `rerun` (`--- чанк 0 ---`). Первая редакция
# требовала скобок — и на `rerun` разобрала НОЛЬ чанков, после чего прибор напечатал «порог не
# пробит» и «гипотеза не опровергнута» НА ПУСТОМ ЗНАМЕНАТЕЛЕ. Отсюда же гейт `assert_measured`.
CHUNK_RE = re.compile(r"^--- чанк (\d+)(?: \[([^\]]*)\])? ---$", re.M)
HAN_RE = re.compile(r"[一-鿿]")
def load_glossary(con, book_id):
aliases = defaultdict(list)
for term_id, alias in con.execute(
"select term_id, alias from glossary_aliases where book_id=?", (book_id,)
):
aliases[term_id].append(alias)
rows = []
for (tid, src, dst, typ, status, gender, decl, allow_short) in con.execute(
"select id, src, dst, type, status, gender, decl, allow_short "
"from glossary where book_id=? order by id",
(book_id,),
):
forms = []
if decl:
try:
forms = [f for f in json.loads(decl).get("forms", []) if f]
except json.JSONDecodeError:
pass
keys = [src] + aliases[tid]
# Структурное исключение 2: односимвольный ханьский ключ без allow_short в автомат не попал.
eligible = [k for k in keys if allow_short or not (len(k) == 1 and HAN_RE.match(k))]
rows.append(
{
"id": tid, "src": src, "dst": dst, "type": typ, "status": status,
"gender": gender, "keys": eligible, "banned": [k for k in keys if k not in eligible],
"decl_forms": forms, "accepted": sorted({dst, *forms}, key=len, reverse=True),
"confirmed": status == "approved",
"stem": dst[:STEM_MIN] if len(dst) >= STEM_MIN else dst,
}
)
return rows
def split_chunks(text):
out = []
chapters = list(CHAPTER_RE.finditer(text))
for i, cm in enumerate(chapters):
ch = int(cm.group(1))
body = text[cm.end(): chapters[i + 1].start() if i + 1 < len(chapters) else len(text)]
marks = list(CHUNK_RE.finditer(body))
for j, km in enumerate(marks):
seg = body[km.end(): marks[j + 1].start() if j + 1 < len(marks) else len(body)]
translated = "[ПЕРЕВОД]" in seg
zh, ru = (seg.split("[ПЕРЕВОД]", 1) if translated else (seg, ""))
out.append((ch, int(km.group(1)), km.group(2) or "", zh.replace("[ОРИГ]", ""), ru,
translated))
return out
def assert_measured(what, n, ctx):
"""ГЕЙТ ПРОТИВ ЗЕЛЕНИ НА ПУСТОТЕ. Прибор, ничего не измеривший, обязан ОТКАЗАТЬ, а не напечатать
«порог не пробит»: пустой знаменатель читается как успех и ровно так и был прочитан 30.08, когда
парсер не понял формат `rerun` и вердикт «гипотеза не опровергнута» встал на нуле наблюдений."""
if n <= 0:
print(f"\n⛔⛔ ПРИБОР ОТКАЗЫВАЕТ: {what} = 0 ({ctx}). Вердикт НЕ выносится: "
"нулевой знаменатель — это не «в пределах», это отсутствие замера.")
sys.exit(2)
def count_keys(zh, terms, mode):
"""mode=native — longest-match (автомат движка); mode=substring — независимо на термин (июль)."""
hits = {}
if mode == "substring":
for t in terms:
n = sum(zh.count(k) for k in t["keys"])
if n:
hits[t["id"]] = n
return hits
occupied = bytearray(len(zh))
ordered = sorted(
(t for t in terms if t["keys"]),
key=lambda t: max(len(k) for k in t["keys"]), reverse=True,
)
for t in ordered:
n = 0
for key in sorted(t["keys"], key=len, reverse=True):
start = 0
while (p := zh.find(key, start)) >= 0:
if not any(occupied[p:p + len(key)]):
occupied[p:p + len(key)] = b"\x01" * len(key)
n += 1
start = p + 1
if n:
hits[t["id"]] = n
return hits
def engine_state(con, book_id):
st = {}
for ch, idx, n_miss, detail, sticky, spoil, evict, exact in con.execute(
"select chapter, chunk_idx, n_postcheck_miss, postcheck_detail, n_sticky, "
"n_spoiler_blocked, n_evicted, n_exact_hits from retrieval_state where book_id=?",
(book_id,),
):
items = json.loads(detail) if detail else []
st[(ch, idx)] = {
"confirmed_miss": {d["src"] for d in items if d.get("disp") == "confirmed"},
"ambiguous_miss": {d["src"] for d in items if d.get("disp") == "ambiguous"},
"n_miss": n_miss, "sticky": sticky, "spoiler": spoil,
"evicted": evict, "exact": exact,
}
return st
def classify(term, ru):
"""Промах уже установлен: ни одной принятой формы нет. Чем он является?"""
ru_low = ru.lower()
if any(f and f.lower() in ru_low for f in term["decl_forms"]):
return "decl_gap" # контроль: обязан быть 0 при union D24.4
if term["stem"] and term["stem"].lower() in ru_low:
return "inflection_gap" # основа есть, формы в сиде нет — дефект ЗАСЕВА
return "genuine_absent" # КАНДИДАТ дрейфа, читать глазами
def audit(run, mode, verbose):
cfg = RUNS[run]
con = sqlite3.connect(f"file:{cfg['db']}?mode=ro", uri=True)
book_id = cfg["book_id"] or con.execute("select book_id from glossary limit 1").fetchone()[0]
terms = load_glossary(con, book_id)
by_id = {t["id"]: t for t in terms}
chunks = split_chunks(cfg["parallel"].read_text(encoding="utf-8"))
engine = engine_state(con, book_id)
print("=" * 98)
print(f"АУДИТ УДЕРЖАНИЯ БАНКА — прогон `{run}` ({book_id}), режим матчинга: {mode}")
print(f" {cfg['note']}")
print(" правило решения — в шапке файла, записано ДО чисел")
print("=" * 98)
banned = sorted({k for t in terms for k in t["banned"]})
print(f"записей {len(terms)}: confirmed {sum(t['confirmed'] for t in terms)}, "
f"ambiguous {sum(not t['confirmed'] for t in terms)} | "
f"ключей вне автомата (single-Han ban): {', '.join(banned) or ''}")
print(f"чанков {len(chunks)}, из них переведено {sum(c[5] for c in chunks)}; "
f"строк retrieval_state {len(engine)}")
assert_measured("разобрано чанков параллельного файла", len(chunks),
f"формат {cfg['parallel'].name} не распознан")
assert_measured("переведённых чанков", sum(c[5] for c in chunks), "нечего проверять")
fired, fired_w, miss_ch, miss_w = (defaultdict(int) for _ in range(4))
klass = defaultdict(int)
kandidaty, rows = [], []
agree = only_engine = only_mine = 0
for ch, idx, flag, zh, ru, translated in chunks:
if not translated:
continue # структурное исключение 1
hits = count_keys(zh, terms, mode)
ru_low = ru.lower()
eng = engine.get((ch, idx), {})
eng_miss_all = eng.get("confirmed_miss", set()) | eng.get("ambiguous_miss", set())
for tid, n in hits.items():
t = by_id[tid]
fired[tid] += 1
fired_w[tid] += n
# ⚠ РЕГИСТР: сравнение регистро-НЕчувствительное. Прямое `in` дало ложный промах на
# 开窍 → «открытие апертуры» (гл.5/ч1): в тексте «Открытие апертуры удалось!» — термин
# верен, заглавная лишь начинает предложение. Поймано чтением 30.08.
held = any(f and f.lower() in ru_low for f in t["accepted"])
if not held:
miss_ch[tid] += 1
miss_w[tid] += n
k = classify(t, ru)
klass[(k, t["confirmed"])] += 1
if k == "genuine_absent" and t["confirmed"]:
kandidaty.append((ch, idx, t["src"], t["dst"], t["type"], n))
eng_miss = t["src"] in eng_miss_all
if not held and eng_miss:
agree += 1
elif eng_miss and held:
only_engine += 1
rows.append(("ЛОЖНАЯ ТРЕВОГА ГЕЙТА", ch, idx, t["src"], t["dst"], t["status"], n))
elif not held and not eng_miss:
only_mine += 1
rows.append(("СЛЕПОТА ГЕЙТА", ch, idx, t["src"], t["dst"], t["status"], n))
def block(conf, label):
ids = [i for i in fired if by_id[i]["confirmed"] == conf]
c_all, c_miss = sum(fired[i] for i in ids), sum(miss_ch[i] for i in ids)
w_all, w_miss = sum(fired_w[i] for i in ids), sum(miss_w[i] for i in ids)
print(f"\n--- {label} ---")
print(f" пар (термин, чанк) со сработавшим ключом : {c_all}")
print(f" из них промах (presence-провал) : {c_miss}"
+ (f" ⇒ presence {1 - c_miss / c_all:.1%}" if c_all else ""))
print(f" вхождений ключа всего / промахнуто : {w_all} / {w_miss}"
+ (f" ⇒ occurrence {1 - w_miss / w_all:.1%}" if w_all else ""))
return (w_miss / w_all) if w_all else 0.0, c_all, c_miss
weighted, conf_all, conf_miss = block(True, "CONFIRMED — ОСНОВНОЙ ЭНДПОЙНТ")
assert_measured("пар CONFIRMED со сработавшим ключом", conf_all,
"ни один подписанный ключ не сработал — матчер или данные не те")
block(False, "AMBIGUOUS — справка: обязательности движок не объявлял")
print("\n--- классификация промахов (июльская номенклатура, для сверки) ---")
for k in ("decl_gap", "inflection_gap", "genuine_absent"):
c, a = klass[(k, True)], klass[(k, False)]
note = {"decl_gap": "контроль union D24.4 — обязан быть 0",
"inflection_gap": "дефект ЗАСЕВА (мн.ч.), не дрейф",
"genuine_absent": "КАНДИДАТ дрейфа — читать глазами"}[k]
print(f" {k:<16} confirmed {c:>4} ambiguous {a:>4}{note}")
eff_num = conf_all - klass[("genuine_absent", True)] - klass[("decl_gap", True)]
if conf_all:
print(f" эффективная консистентность CONFIRMED = {eff_num}/{conf_all} = {eff_num / conf_all:.1%}"
" (читатель видит верный термин: инфлексия вне сида ему не мешает)")
print(f"\n--- метрика B: ядро (топ-{TOP_N} по весу ключа среди CONFIRMED) ---")
core = sorted((i for i in fired if by_id[i]["confirmed"]),
key=lambda i: fired_w[i], reverse=True)[:TOP_N]
core_fail = []
print(f" {'термин':<34}{'вес':>6}{'чанков':>8}{'промах':>8}{'доля':>7}")
for i in core:
share = miss_ch[i] / fired[i]
if share >= CORE_MISS_MAX:
core_fail.append(by_id[i]["src"])
print(f" {by_id[i]['src'] + '' + by_id[i]['dst']:<34}{fired_w[i]:>6}{fired[i]:>8}"
f"{miss_ch[i]:>8}{share:>6.0%}{'' if share >= CORE_MISS_MAX else ''}")
print("\n--- ВТОРОЙ КОНТУР: сверка с движковым пост-чеком ---")
print(f" согласие : {agree}")
print(f" ⚠ ложная тревога гейта (форма В ТЕКСТЕ есть): {only_engine}"
" ← ожидаемо: базы писаны ДО фикса D24.4")
print(f" ⚠ слепота гейта (промах, гейт молчит) : {only_mine}")
if verbose and rows:
print(f" {'класс':<24}{'гл':>4}{'чанк':>6} {'src':<12}{'dst':<30}{'статус':<10}{'вес':>5}")
for kind, ch, idx, src, dst, status, n in rows:
print(f" {kind:<24}{ch:>4}{idx:>6} {src:<12}{dst:<30}{status:<10}{n:>5}")
if kandidaty:
print(f"\n--- КАНДИДАТЫ ДРЕЙФА (genuine_absent, confirmed): {len(kandidaty)} — ЧИТАТЬ ГЛАЗАМИ ---")
print(f" {'гл':>4}{'чанк':>6} {'src':<12}{'dst':<30}{'тип':<10}{'вес':>5}")
for ch, idx, src, dst, typ, n in kandidaty:
print(f" {ch:>4}{idx:>6} {src:<12}{dst:<30}{typ:<10}{n:>5}")
print("\n--- зоны, которые прибор НЕ судит (печатаются, а не заминаются) ---")
print(f" чанков без перевода (гейт покрытия) : {sum(1 for c in chunks if not c[5])}")
print(f" sticky-переносов (инерция сцены, A5) : {sum(e['sticky'] for e in engine.values())}")
print(f" вытеснено бюджетом токенов : {sum(e['evicted'] for e in engine.values())}")
print(f" спойлер-блокировок : {sum(e['spoiler'] for e in engine.values())}")
print("\n" + "=" * 98)
a_fail = weighted > WEIGHTED_MISS_MAX
print(f"МЕТРИКА A: взвешенная доля промахов CONFIRMED = {weighted:.2%} при пороге "
f"{WEIGHTED_MISS_MAX:.0%}{'⛔ ПОРОГ ПРОБИТ' if a_fail else 'в пределах'}")
print(f"МЕТРИКА B: терминов ядра с долей промахов >= {CORE_MISS_MAX:.0%}: {len(core_fail)}"
f"{'' + ', '.join(core_fail) if core_fail else ''}"
f"{'⛔ ПОРОГ ПРОБИТ' if core_fail else 'в пределах'}")
print("ВЫВОД (строгий presence): гипотеза «банк держит» в СИЛЬНОЙ форме "
+ ("⛔ ЛОЖНА" if (a_fail or core_fail) else "не опровергнута"))
print("⚠ Границы: терминная половина Вопроса 0 · июльский профиль · редактор glm-5, не боевой "
"deepseek-v4-pro.")
print("=" * 98)
def main():
ap = argparse.ArgumentParser(description="аудит удержания банка, $0")
ap.add_argument("--run", choices=[*RUNS, "both"], default="both")
ap.add_argument("--match", choices=["native", "substring"], default="native",
help="native — автомат движка; substring — как независимый июльский скрипт")
ap.add_argument("--verbose", action="store_true", help="печатать построчную сверку с гейтом")
a = ap.parse_args()
for run in ([*RUNS] if a.run == "both" else [a.run]):
audit(run, a.match, a.verbose)
print()
return 0
if __name__ == "__main__":
sys.exit(main())