#!/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())