#!/usr/bin/env python3 """СЧЁТЧИКИ ДЕФЕКТ-КЛАССОВ — первичный прибор панели-0. $0, детерминированно. ⚠ ЗАЧЕМ ОТДЕЛЬНЫЙ ПРИБОР, КОГДА ЕСТЬ СЛЕПОЕ ЧТЕНИЕ. Ранговый прибор при наших размерах безнадёжен: эффект в ОДНО место стоит 40 глав Гу на китайской паре и 68 на английской (мощностная таблица консилиума 22.08). Панель-0 — 7.8 главы Гу, её MDE ≈ 2 места. ⇒ ранговый эндпойнт этой панели объявлен ОПИСАТЕЛЬНЫМ ЗАРАНЕЕ, а несущим становится СЧЁТНЫЙ: дефект-класс, ломающийся в 20–30% глав при ~0 у компаратора, ловится уже на 25–30 единицах. ⚠ И ЭТО ЖЕ ДОЛГ ШАГА 5.2 ПЛАНА. Покупать 15 глав Гу под вопрос о кросс-главной консистентности, не имея счётчиков, значит повторить класс В20 ревью — оплаченные клетки, которых не прочёл ни один прибор. Счётчики отлаживаются ЗДЕСЬ, на уже купленном, и только потом едут в дорогой замер. ЧТО СЧИТАЕТСЯ (четыре класса, объявлены ДО снятия — пре-рег Д32.0): род формы 1 л. ед. ч. прош. вр. с неверным родом. ТРИ непересекающихся подкласса: «эталон» — пол голоса внутреннего рассказа по окну УСТАНОВЛЕН, форма ему противоречит; «разнобой» — пол по окну НЕ устанавливается, но арм ставит в одном слое ОБА рода (источник-независимый дефект: винить за выбор нельзя, за два сразу можно); «речь» — род, не принадлежащий ни одному объявленному цитируемому говорящему язык непереведённый кусок исходного языка (латиница), КРОМЕ римских цифр и вставной французской РЕПЛИКИ (её и только её боевой пар-мандат велит оставить чужой; имена, топонимы и «шато/мадемуазель» он велит передавать кириллицей — латиница на их месте есть тот самый дефект). На всю панель такая реплика ОДНА: «A farmer's son, oui.» приём авторский типографский приём исходника (разрядка «P A T I E N C E»), ПОТЕРЯННЫЙ выходом банк термины банка, чья канонная форма в выходе встречается реже, чем в исходнике ⛔⛔ ЭТАЛОН СНЯТ ПО ИСХОДНИКУ И СЛЕПО К ПЕРЕВОДАМ, И ИНАЧЕ ОН НЕ СТОИТ НИЧЕГО. Требование п.3а плана: «эталон грепов фиксируется ДО снятия, иначе их можно подогнать под увиденное». Исполнено буквально: каждую главу читали три независимых агента (строитель + два скептика, один из них с мандатом ОПРОВЕРГНУТЬ), которым был открыт РОВНО ОДИН файл — английский исходник главы; русские переводы, код зоны и отчёт им были закрыты. Расхождения разрешены руками и названы в отчёте поимённо. Эталон лежит рядом файлом и фризится своим коммитом ДО сборки пакетов. ⚠⚠ ПЕРВЫЙ КРУГ ЭТАЛОНА БЫЛ НЕГОДЕН, И ЭТО ПОЙМАЛА ЕГО ЖЕ СВЕРКА. Вопрос был задан про «пол рассказчика», а книга РАМОЧНАЯ: хронист пишет в третьем лице, а рассказ героя идёт от первого лица внутри кавычек. Половина строителей сочла внутренний рассказ «прямой речью» и объявила первое лицо отсутствующим — на восьми главах из шестнадцати. Круг переспрошен с НАЗВАННОЙ рамкой; переводы агентам оставались закрыты в обоих кругах, то есть исправлена постановка вопроса, а не подогнан ответ. Оба круга лежат в эталоне, расхождение видно. ⚠ ПРИБОР ПЕЧАТАЕТ СТРОКИ, А НЕ ТОЛЬКО ЧИСЛА (требование п.3а). Счёт без улик не проверяем никем, включая меня: ровно так «13 катастроф» эксп-13 оказались пятью-шестью при оверфлаге судьи. ⚠ ЧЕГО ПРИБОР НЕ УМЕЕТ, И ЭТО ОБЪЯВЛЕНО ДО ЗАМЕРА: · рассказчик 1-го лица без имени лукапу по банку недоступен — род берётся из эталона главы, а не из банка (полей пола в `bank-en.json` нет вовсе, см. Шаг 7 плана); · разделение «повествование против прямой речи» механическое (см. `_speech_spans`) и у разных армов может лечь по-разному — поэтому классификация КАЖДОГО попадания печатается, а число попаданий, сменивших класс между армами, выносится в свод отдельной строкой; · класс «приём» на этой панели применим к ОДНОЙ главе из шестнадцати — это анекдот, а не класс, и он объявляется описательным до того, как будет посчитан. Запуск: schet.py --selftest гейты прибора, включая ФАЛЬСИФИКАЦИЮ на подсаженном браке schet.py --report [армы…] таблица по главам и агрегат со знаковым тестом schet.py --hits <АРМ> [uid] все попадания с контекстом — для чтения глазами """ from __future__ import annotations import importlib.util import json import re import sys from pathlib import Path REPO = Path("/home/ubuntu/projects/textmachine") ZONE = Path(__file__).resolve().parent for d in ("dovodka", "editor_tier", "tenant_panel", "role_topology", "bank_arbitration"): sys.path.insert(0, str(REPO / "eval" / d)) def _load(name: str, path: Path): spec = importlib.util.spec_from_file_location(name, path) mod = importlib.util.module_from_spec(spec) sys.modules[name] = mod spec.loader.exec_module(mod) return mod # ⚠ Тексты армов берутся У `rol`, а не читаются заново: там уже стоят реестры источников и гейт # годности клетки чужой фазы. Второй загрузчик разъехался бы с ним молча. R = _load("rol_schet", ZONE / "rol.py") ZS = _load("zsud_schet", ZONE / "zsud.py") # знаковый тест — один на зону, не второй ETALON = ZONE / "etalon-panel0.json" PAIR = "en" PANEL = ("ZD", "Z0", "ZF", "RG", "E6") # ── КЛАСС «РОД» ────────────────────────────────────────────────────────────────────────────── # ⚠ ОТБОР ФОРМ УЖЕСТОЧЁН ПОСЛЕ ПРОВЕРКИ ИСПОЛНЕНИЕМ, И ПЕРВАЯ РЕДАКЦИЯ БЫЛА НЕГОДНОЙ. Она брала # любую форму на -л/-ла, у которой в окне ±40 знаков стояло «я», — и считала третьи лица # («Габриэль пожал плечами») и СУЩЕСТВИТЕЛЬНЫЕ на -л («Пьющий пепел»). На главе `f2274720c9` она # давала 2ж/5м там, где настоящих форм 1 л. три и все мужские. ⇒ местоимение обязано стоять в # синтаксической связи: сразу перед глаголом (через 0–3 служебных слова) или сразу после него. _ADV = (r"(?:не|уже|бы|тоже|также|всё|ещё|лишь|только|чуть|едва|снова|вновь|даже|так|там|тут|" r"потом|тогда|сразу|вдруг|наконец|почти|словно|будто|нехотя|молча|тихо|быстро)\s+") _FORM = r"[а-яё]+л(?:ась|ся|а)?" _RE_ROD = (re.compile(rf"\bя\s+(?:{_ADV}){{0,3}}({_FORM})\b", re.I), re.compile(rf"\b({_FORM})\s+(?:{_ADV}){{0,1}}я\b", re.I)) _FEM = ("ла", "лась") # ── КЛАСС «ЯЗЫК» ───────────────────────────────────────────────────────────────────────────── _RE_LAT = re.compile(r"[A-Za-z][A-Za-z'’\-]+") # ⚠ Римская цифра — НЕ кусок исходного языка: «Людовик VIII» законен в русской прозе и стоит во # всех армах одинаково. Считать её браком значило бы вписать в счётчик константу шума. _RE_ROMAN = re.compile(r"^[IVXLCDM]+$") # ⛔ ЛАТИНИЦА В РАЗРЯДКУ — ОТДЕЛЬНЫЙ ДЕТЕКТОР, И ЕГО НЕ БЫЛО. Первая редакция `_RE_LAT` требовала # от слова ДВУХ букв подряд, а `P A T I E N C E` — восемь одиночных букв через пробел: класс «язык» # был к ней слеп по построению. Поймано чтением улик: `RG` единственный сохранил авторскую разрядку # главы `197f98eb61` — и сохранил её ПО-АНГЛИЙСКИ, то есть приём удержал, а слово не перевёл. # Без этого детектора свод печатал бы «приём цел, языка нет» и хвалил бы арм за непереведённое слово. _RE_LAT_SP = re.compile(r"(?:(?<=\s)|^)(?:[A-Za-z]\s){2,}[A-Za-z](?=[\s.,!?:;—»]|$)") # ── КЛАСС «ПРИЁМ» ──────────────────────────────────────────────────────────────────────────── # Разрядка: три и более одиночных буквы через пробел. Ищется В ВЫХОДЕ по факту наличия ЛЮБОЙ # разрядки, а не по совпадению букв: русское слово другое, приём тот же. _RE_SPACED = re.compile(r"(?:(?<=\s)|^)(?:[A-Za-zА-Яа-яЁё]\s){2,}[A-Za-zА-Яа-яЁё](?=[\s.,!?:;—]|$)") def etalon() -> dict: if not ETALON.exists(): raise SystemExit(f"⛔ эталона нет ({ETALON.name}). Он снимается ПО ИСХОДНИКУ и ДО замера — " "снять его после того, как счётчик показал числа, значит подогнать") return json.loads(ETALON.read_text(encoding="utf-8")) def _paras(t: str) -> list[tuple[int, str]]: """(смещение, абзац). Абзац — строка: русская вёрстка реплик поабзацная.""" out, pos = [], 0 for line in t.split("\n"): if line.strip(): out.append((pos, line)) pos += len(line) + 1 return out def _speech_spans(t: str) -> list[tuple[int, int]]: """Куски текста, лежащие в ПРЯМОЙ РЕЧИ, а не в повествовании. ⚠ Правило механическое и объявлено ДО замера: абзац-реплика начинается с тире; каждое «закрывающее» тире (`, — `, `. — `, `? — `, `! — `, `… — `) ПЕРЕКЛЮЧАЕТ слой. Куски чётные — речь, нечётные — слова автора, то есть повествование. Нет ни одного тире — речью считается весь абзац. ⛔⛔ ПЕРВАЯ РЕДАКЦИЯ БЫЛА НЕГОДНОЙ, И ПОЙМАЛО ЕЁ ЧТЕНИЕ УЛИК ГЛАЗАМИ, А НЕ ГЕЙТ. Она обрывала речь на ПЕРВОМ тире и всё дальнейшее звала повествованием — то есть не знала ВОЗОБНОВЛЕНИЯ реплики, а русская вёрстка диалога именно чередуется: «— речь, — слова автора. — речь». Цена ошибки замерена: на главе `49ca859c94` реплика Жан-Франсуа «…как и я, мадемуазель Кастия. Нет, я сбежал в Огюстен…» ложилась в ПОВЕСТВОВАНИЕ, и мужская форма мужского говорящего шла в дефекты рассказчицы — у ВСЕХ ТРЁХ армов сразу. Это класс «прибор производит брак вместо того, чтобы его находить»; общий знаменатель парного контраста не портил, но абсолютные числа врал. """ out = [] for pos, line in _paras(t): s = line.lstrip() if not s.startswith(("—", "–", "-")): continue head = pos + (len(line) - len(s)) cuts = [m.start() + 1 for m in re.finditer(r"[,.!?…»]\s*[—–]\s", s)] b = [0, *cuts, len(s)] out += [(head + b[k], head + b[k + 1]) for k in range(len(b) - 1) if k % 2 == 0] return out def rod_hits(text: str) -> list[dict]: """Все формы 1 л. ед. ч. прош. вр. с родом, местом и классом «речь/повествование».""" sp = _speech_spans(text) seen, out = set(), [] for rx in _RE_ROD: for m in rx.finditer(text): i = m.start(1) if i in seen: continue seen.add(i) w = m.group(1).lower() lo, hi = max(0, i - 60), min(len(text), m.end(1) + 30) out.append({"i": i, "слово": w, "род": "ж" if w.endswith(_FEM) else "м", "речь": any(a <= i < b for a, b in sp), "ктx": text[lo:hi].replace("\n", " ")}) return sorted(out, key=lambda x: x["i"]) def rod_defects(text: str, et: dict) -> list[dict]: """Попадания, чей род ЭТАЛОНУ ПРОТИВОРЕЧИТ. Два ПОДКЛАССА, и они не пересекаются. ⚠⚠ ПОДКЛАСС «ЭТАЛОН» работает там, где пол голоса внутреннего рассказа ПО ОКНУ УСТАНОВЛЕН. Тогда каждая форма слоя повествования обязана нести именно его. Это сильная проверка. ⚠⚠ ПОДКЛАСС «РАЗНОБОЙ» работает там, где пол по окну НЕ устанавливается, — и он ИСТОЧНИК- НЕЗАВИСИМ. Окно в 1600 знаков часто не даёт улики вовсе; арм видел ровно то же окно и большего знать не мог, поэтому винить его за выбор рода нельзя. Но нельзя и не винить за ДВА рода сразу в одном слое одной главы: это ошибка внутренняя, для неё исходника не требуется. Ровно её владелец увидел сам («дрейф пола Рейн между отрывками, у всех четырёх армов») и пометил как то, «что счётчик не увидит». Теперь увидит. ⚠ ПРЯМАЯ РЕЧЬ проверяется СЛАБО и это объявляется: говорящих несколько, дефектом считается только род, не принадлежащий НИ ОДНОМУ объявленному говорящему. А если хоть у одного из них пол по окну не устанавливается, речь не проверяется ВОВСЕ — иначе законная форма неизвестного говорящего пошла бы в дефекты, и счётчик стал бы производить брак вместо того, чтобы его находить. """ voice = et.get("род_голоса") speakers = et.get("говорящие", []) allowed = ({voice} | {s.get("род") for s in speakers}) - {None, "unknown"} # ⛔ РЕЧЬ НЕ ПРОВЕРЯЕТСЯ, ЕСЛИ ХОТЬ ОДИН ГОВОРЯЩИЙ ГЛАВЫ НЕ ИМЕЕТ ОБЪЯВЛЕННОГО ПОЛА — включая # САМОГО РАССКАЗЧИКА. Первая редакция этого не делала, и вот что выходило: на главе # `5a8f48d24a` пол голоса по окну не устанавливается, в списке остались только Персиваль и # Вульфрик (оба «м»), и собственная реплика рассказчицы «— Где я была?» шла в дефекты как # «род ж не принадлежит ни одному говорящему главы». Прибор ловил не арм, а сам себя. speech_blind = voice in (None, "unknown") or any( s.get("род") in (None, "unknown") for s in speakers) bad, narr = [], [] for h in rod_hits(text): if h["речь"]: if allowed and not speech_blind and h["род"] not in allowed: bad.append(dict(h, подкласс="речь", почему=f"род {h['род']} не принадлежит ни одному говорящему главы " f"{sorted(allowed)}")) continue narr.append(h) if voice in ("ж", "м") and h["род"] != voice: bad.append(dict(h, подкласс="эталон", почему=f"повествование: голос рассказа {voice}, форма {h['род']}")) if voice not in ("ж", "м") and len({h["род"] for h in narr}) > 1: # ⚠ Дефектом считается МЕНЬШИНСТВО форм, а не все: разнобой из 3ж+1м есть одна ошибка, # а не четыре, и мерить его надо так же, как подкласс «эталон», иначе агрегат перекосит. few = min({"ж", "м"}, key=lambda g: sum(1 for h in narr if h["род"] == g)) for h in narr: if h["род"] == few: bad.append(dict(h, подкласс="разнобой", почему="в слое повествования одной главы стоят ОБА рода; пол по " "окну не устанавливается, поэтому дефект — меньшинство форм")) return bad def lang_defects(text: str, et: dict) -> list[dict]: """Куски исходного языка. Законные иноязычные вставки эталона и римские цифры не в счёт.""" # ⚠ БЕЛЫЙ СПИСОК — НЕ «всё французское», а РОВНО вставная реплика. Основание — боевой # пар-мандат `en-ru/translator.md`, секция ФРАНЦУЗСКИЙ СЛОЙ: имена и топонимы ТРАНСКРИБИРУЮТСЯ, # мадемуазель/месье/шато идут РУССКОЙ традицией галлицизмов, и чужими остаются только вставные # французские РЕПЛИКИ. Значит `château` или `Capitaine` латиницей в русском выходе есть тот # самый дефект, который класс ловит, а не законная вставка. ok = {w.lower() for ins in et.get("латиница_законна", []) for w in _RE_LAT.findall(ins)} out, taken = [], [] for m in _RE_LAT_SP.finditer(text): w = m.group(0) flat = w.replace(" ", "") taken.append((m.start(), m.end())) if flat.lower() in ok or _RE_ROMAN.match(flat): continue lo, hi = max(0, m.start() - 45), min(len(text), m.end() + 25) out.append({"i": m.start(), "слово": w, "ктx": text[lo:hi].replace("\n", " "), "почему": "латиница В РАЗРЯДКУ: авторский приём удержан, слово НЕ переведено"}) for m in _RE_LAT.finditer(text): w = m.group(0) if w.lower() in ok or _RE_ROMAN.match(w) or any(a <= m.start() < b for a, b in taken): continue lo, hi = max(0, m.start() - 45), min(len(text), m.end() + 25) out.append({"i": m.start(), "слово": w, "ктx": text[lo:hi].replace("\n", " "), "почему": "латиница вне вставной французской реплики"}) return sorted(out, key=lambda x: x["i"]) def priem_defects(text: str, et: dict) -> list[dict]: """Авторский типографский приём исходника, которого в выходе НЕТ.""" if not et.get("приёмы"): return [] if _RE_SPACED.search(text): return [] return [{"i": -1, "слово": "", "ктx": "; ".join(x["текст"] for x in et["приёмы"]), "почему": "разрядка исходника в выходе отсутствует"}] def bank_defects(src: str, text: str) -> list[dict]: return [{"i": -1, "слово": g, "ктx": "", "почему": "канонная форма банка не доехала"} for g in R.C.canon_gaps(src, text)] CLASSES = ("род", "язык", "приём", "банк") def defects(src: str, text: str, et: dict) -> dict: return {"род": rod_defects(text, et), "язык": lang_defects(text, et), "приём": priem_defects(text, et), "банк": bank_defects(src, text)} # ── СВОД ───────────────────────────────────────────────────────────────────────────────────── def collect(arms: tuple = PANEL) -> tuple[list, dict]: et_all = etalon() R.PAIRS[PAIR]["wire"]() us, data, empty = [], {}, [] for u in R.chosen(PAIR): if u["uid"] not in et_all: continue tx = {a: R.arm_text(PAIR, a, u["uid"]) for a in arms} # ⛔ ГЛАВА С ПУСТЫМ ТЕКСТОМ ХОТЬ У ОДНОГО АРМА ИЗ СЧЁТА ВЫБРАСЫВАЕТСЯ, И ВСЛУХ. Пустой текст # дефектов не имеет по построению, то есть арм с пропавшей клеткой выглядел бы ЛУЧШИМ. # Ровно так мина двух контуров чуть не подарила черновику «чистую» главу (см. rol._wire). if not all(tx[a].strip() for a in arms): empty.append((u["uid"], [a for a in arms if not tx[a].strip()])) continue us.append(u) for a in arms: data[(u["uid"], a)] = defects(u["source"], tx[a], et_all[u["uid"]]) if empty: print(f" ⛔ ГЛАВ ВЫБРОШЕНО ИЗ СЧЁТА (пустой текст у арма): {len(empty)}") for uid, aa in empty: print(f" {uid}: пусто у {aa}") return us, data def report(arms: tuple = PANEL) -> int: et_all = etalon() us, data = collect(arms) print(f"\n=== СЧЁТНЫЙ ПРИБОР ПАНЕЛИ-0 · пара {PAIR} · глав {len(us)} · армы {' '.join(arms)} ===") print("⚠ первичная величина — ЧИСЛО ГЛАВ С ≥1 ФАТАЛОМ (агрегат по четырём классам);\n" " по-классовые разбивки вторичны; ранги этой панели объявлены ОПИСАТЕЛЬНЫМИ заранее\n") for cl in CLASSES: print(f" {'глава':12s}" + "".join(f"{a:>6s}" for a in arms) + f" класс «{cl}»") for u in us: row = [len(data[(u["uid"], a)][cl]) for a in arms] mark = "" if len(set(row)) == 1 else " ←" print(f" {u['uid']:12s}" + "".join(f"{x:6d}" for x in row) + mark) print(f" {'ИТОГО':12s}" + "".join(f"{sum(len(data[(u['uid'], a)][cl]) for u in us):6d}" for a in arms) + "\n") print(f" {'глава':12s}" + "".join(f"{a:>6s}" for a in arms) + " ВСЕ КЛАССЫ (фаталов)") tot = {a: 0 for a in arms} chap = {a: 0 for a in arms} for u in us: row = [] for a in arms: n = sum(len(data[(u["uid"], a)][c]) for c in CLASSES) tot[a] += n chap[a] += n > 0 row.append(n) print(f" {u['uid']:12s}" + "".join(f"{x:6d}" for x in row)) print(f" {'фаталов':12s}" + "".join(f"{tot[a]:6d}" for a in arms)) print(f" {'глав с ≥1':12s}" + "".join(f"{chap[a]:6d}" for a in arms) + f" из {len(us)}") # ⚠ Сколько попаданий сменило класс «речь/повествование» между армами — цена механического # правила. Печатается всегда, а не только когда мала. moved = 0 for u in us: cls = [{h["слово"]: h["речь"] for h in rod_hits(R.arm_text(PAIR, a, u["uid"]))} for a in arms] keys = set().union(*cls) moved += sum(1 for k in keys if len({c[k] for c in cls if k in c}) > 1) print(f"\n ⚠ попаданий, сменивших класс «речь/повествование» между армами: {moved}") def _test(title: str, val) -> None: print(f"\n {title}") for i, a in enumerate(arms): for b in arms[i + 1:]: d = [val(u["uid"], a) - val(u["uid"], b) for u in us] nz = [x for x in d if x] p = ZS._sign_p(d) # noqa: SLF001 print(f" {a} − {b}: сумма {sum(d):+4d} · глав с разницей {len(nz):2d} " f"(в пользу {b}: {sum(1 for x in nz if x > 0)}) · p={p:.4f}" f"{' РАЗЛИЧИМО' if p < 0.05 else ''}") def _all(uid: str, a: str) -> int: return sum(len(data[(uid, a)][c]) for c in CLASSES) # ⛔ ПЕРВИЧНОЕ ПРАВИЛО ПЕЧАТАЕТСЯ ПЕРВЫМ И ПЕЧАТАЕТСЯ ВСЕГДА, каким бы ни вышло. Оно объявлено # п.3б плана ДО чисел: величина — БИНАРНАЯ «есть ли в главе хоть один фатал», тест — парный # знаковый. Подменить его на счётный, увидев, что счётный красивее, значит подогнать правило # под результат; счётный идёт НИЖЕ и назван вторичным, как и объявлено. _test("① ПЕРВИЧНОЕ ПРАВИЛО п.3б — бинарно «глава с ≥1 фаталом», парный знаковый:", lambda uid, a: int(_all(uid, a) > 0)) _test("② вторичное — ЧИСЛО фаталов на главу:", _all) for cl in CLASSES: _test(f"③ вторичное, по классу «{cl}»:", lambda uid, a, c=cl: len(data[(uid, a)][c])) # ── ЧАСТОТА С ИНТЕРВАЛОМ, А НЕ ВЕРДИКТ ────────────────────────────────────────────────── # ⛔ Требование плана 22.08 §4: «редкий класс (ломается в ~10% глав) при 15 главах Гу НЕ # РАЗРЕШАЕТСЯ — по редким печатать ЧАСТОТУ С ИНТЕРВАЛОМ, а не вердикт». Здесь оно и исполняется. # ⚠ И печатается ПОТОЛОК МОЩНОСТИ знакового теста: при всех расхождениях в одну сторону # двусторонний p = 2/2^k, то есть p<0.05 недостижим, пока глав с ненулевой разницей меньше # шести. Класс, который ломается на трёх главах из шестнадцати, эта панель не разрешит НИКОГДА, # как бы односторонен ни был результат. Число печатается, чтобы «не различимо» не читалось как # «одинаково». k = 1 while 2 / 2 ** k >= 0.05: k += 1 print(f"\n ④ ЧАСТОТА ГЛАВ С ДЕФЕКТОМ (доля из {len(us)}) и 95% интервал Уилсона.") print(f" ⚠ потолок мощности: знаковый тест даёт p<0.05 не раньше, чем на {k} главах с " f"ненулевой разницей, ВСЕ в одну сторону (2/2^{k} = {2 / 2 ** k:.4f}).") print(f" {'класс':8s}" + "".join(f"{a:>22s}" for a in arms)) for cl in (*CLASSES, "ВСЕ"): line = f" {cl:8s}" for a in arms: n = len(us) x = sum(1 for u in us if (len(data[(u["uid"], a)][cl]) if cl != "ВСЕ" else _all(u["uid"], a)) > 0) ph, z = x / n, 1.96 den = 1 + z * z / n c = (ph + z * z / (2 * n)) / den hw = z * ((ph * (1 - ph) / n + z * z / (4 * n * n)) ** 0.5) / den line += f"{f'{x}/{n} [{max(0, c - hw):.2f};{min(1, c + hw):.2f}]':>22s}" print(line) print(f"\n ⚠ ПОПАДАНИЙ ВСЕГО (не глав) — описательно, парности не имеет:") for cl in CLASSES: print(f" {cl:8s}" + "".join( f"{sum(len(data[(u['uid'], a)][cl]) for u in us):>22d}" for a in arms)) print(f"\n эталон: {ETALON.name} · глав в эталоне {len(et_all)}") return 0 def hits(arm: str, uid: str = "") -> int: """Все попадания с контекстом — чтобы улики читались ГЛАЗАМИ, а не принимались на слово.""" et_all = etalon() R.PAIRS[PAIR]["wire"]() for u in R.chosen(PAIR): if u["uid"] not in et_all or (uid and not u["uid"].startswith(uid)): continue et = et_all[u["uid"]] t = R.arm_text(PAIR, arm, u["uid"]) d = defects(u["source"], t, et) n = sum(len(v) for v in d.values()) print(f"\n{'=' * 100}\n{u['uid']} арм {arm} фаталов {n} " f"эталон: рассказчик {et.get('род_голоса')} · " f"говорящие {[(s['имя'], s['род']) for s in et.get('говорящие', [])]}") for h in rod_hits(t): print(f" род [{h['род']}] {'речь ' if h['речь'] else 'повес'} " f"{h['слово']:14s} …{h['ктx']}…") for cl in CLASSES: for x in d[cl]: print(f" ⛔ {cl:6s} {x['слово'][:24]:24s} {x['почему']}") return 0 def cmd_selftest() -> int: bad = 0 def ck(n: str, ok: bool, d: str = "") -> None: nonlocal bad bad += not ok print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else "")) # ── 1. ОТБОР ФОРМ. Гейт стоит на том, чем первая редакция болела. for txt, want in (("Габриэль пожал плечами. — Я рассказал ей всё.", ["рассказал"]), ("Фиби. Пьющий пепел. Битва при Санкте.", []), ("Я поймала себя на мягкой улыбке.", ["поймала"]), ("— Дело не в Диор, — прорычал я. — Поедешь с нами.", ["прорычал"]), ("Это была молитва, поняла я.", ["поняла"]), ("Он повесил голову, и я отвернулся к лошади.", ["отвернулся"])): got = [h["слово"] for h in rod_hits(txt)] ck(f"отбор форм: {txt[:44]!r}", got == want, f"нашлось {got}, ожидалось {want}") # ── 2. РЕЧЬ ПРОТИВ ПОВЕСТВОВАНИЯ — механическое правило на наблюдённых формах панели. for txt, want in (("— Дело не в Диор, — прорычал я. — Поедешь с нами.", False), ("— Я велел тебе сказать мэтру, что вернусь.", True), ("Я подошёл к Хоакину и сжал его плечо.", False), ("— Где я была? — Глаза мои сузились.", True), ("— Благого рассвета, брат. — Я кивнул здоровяку.", False), # ⛔ ВОЗОБНОВЛЕНИЕ РЕПЛИКИ ПОСЛЕ СЛОВ АВТОРА — на этом первая редакция и # ломалась: третий кусок абзаца снова речь, а не повествование. ("— Вряд ли, — фыркнул Жан-Франсуа. — Нет, я сбежал в Огюстен.", True), ("— Да, — сказал он, — но я ушёл первым.", True)): h = rod_hits(txt) ck(f"речь/повествование: {txt[:44]!r}", bool(h) and h[0]["речь"] == want, f"{'речь' if h and h[0]['речь'] else 'повествование'}, ожидалось " f"{'речь' if want else 'повествование'}") # ── 3. ⛔ ФАЛЬСИФИКАЦИЯ: счётчик ОБЯЗАН найти брак, ПОДСАЖЕННЫЙ в заведомо чистый текст. # Гейт, который зелен только на здоровом входе, не сторожит ничего — этим был болен селфтест # Б8: он фильтровал арм по наличию файла клетки, файлов не было, и гейт зеленел вхолостую. et_ok = {"род_голоса": "ж", "говорящие": [], "латиница_законна": ["oui"], "приёмы": [{"текст": "P A T I E N C E"}]} clean = "Я поймала себя на улыбке.\nЭто была молитва, поняла я.\nОн сказал oui, и я ушла.\nТ Е Р П Е Н И Е" ck("фальсификация: чистый текст даёт НОЛЬ фаталов", sum(len(v) for v in defects("", clean, et_ok).values()) == 0, str({k: [x["слово"] for x in v] for k, v in defects("", clean, et_ok).items() if v})) for name, sick, cl in ( ("род сломан в повествовании", clean.replace("Я поймала", "Я поймал"), "род"), ("род сломан вторым местом", clean.replace("поняла я", "понял я"), "род"), ("непереведённый кусок исходника", clean.replace("и я ушла", "и я ушла, swift as silver"), "язык"), ("разрядка потеряна", clean.replace("\nТ Е Р П Е Н И Е", ""), "приём"), # ⛔ приём УДЕРЖАН, но слово НЕ ПЕРЕВЕДЕНО — латиница в разрядку. Первая редакция # класса «язык» этого не видела: её регекс требовал двух букв подряд. ("разрядка удержана, но по-английски", clean.replace("Т Е Р П Е Н И Е", "P A T I E N C E"), "язык")): got = defects("", sick, et_ok) ck(f"фальсификация: счётчик ЛОВИТ «{name}»", bool(got[cl]), f"класс «{cl}»: {[x['слово'] or x['почему'] for x in got[cl]] or 'ПУСТ — брак не пойман'}") # обратная сторона: объявленная эталоном вставка браком НЕ считается ck("законная иноязычная вставка эталона браком НЕ считается", not lang_defects("Он сказал oui.", et_ok)) ck("римская цифра браком НЕ считается", not lang_defects("Людовик VIII умер.", et_ok)) # ── 4. ЭТАЛОН ПОКРЫВАЕТ ПАНЕЛЬ, И КАЖДАЯ ГЛАВА В НЁМ ОБЪЯВЛЕНА ЯВНО. # ⚠ «Главы нет в эталоне» обязано быть ПРОВАЛОМ, а не тихим пропуском: `collect` отбирает # только главы эталона, и молчаливая недостача урезала бы панель ровно тем классом, против # которого весь план и написан. if not ETALON.exists(): ck("эталон снят и лежит рядом", False, f"нет файла {ETALON.name}") else: et_all = etalon() R.PAIRS[PAIR]["wire"]() us = [u["uid"] for u in R.chosen(PAIR)] ck("эталон покрывает ВСЕ главы панели", all(u in et_all for u in us), f"нет в эталоне: {[u for u in us if u not in et_all]}") need = ("род_голоса", "говорящие", "латиница_законна", "приёмы", "определим_по_окну") miss = {u: [k for k in need if k not in et_all.get(u, {})] for u in us if u in et_all} ck("у каждой главы эталона объявлены все четыре поля", not any(miss.values()), str({u: v for u, v in miss.items() if v})) ck("род рассказчика объявлен ЗНАЧЕНИЕМ, а не пропуском", all(et_all[u].get("род_голоса") in ("ж", "м", None) for u in us if u in et_all), str({u: et_all[u].get("род_голоса") for u in us if u in et_all and et_all[u].get("род_голоса") not in ("ж", "м", None)})) print(f"\n{'ПРИБОР ГОДЕН' if not bad else f'ПРОВАЛОВ: {bad}'}") return bad if __name__ == "__main__": a = sys.argv[1:] or ["--report"] if a[0] == "--selftest": sys.exit(1 if cmd_selftest() else 0) elif a[0] == "--report": sys.exit(report(tuple(x for x in a[1:] if x.isupper()) or PANEL)) elif a[0] == "--hits": sys.exit(hits(a[1], a[2] if len(a) > 2 else "")) else: raise SystemExit(f"⛔ неизвестный режим {a[0]!r}; знаю --selftest --report --hits")