textmachine/eval/dovodka/schet.py

524 lines
41 KiB
Python
Raw 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
"""СЧЁТЧИКИ ДЕФЕКТ-КЛАССОВ — первичный прибор панели-0. $0, детерминированно.
⚠ ЗАЧЕМ ОТДЕЛЬНЫЙ ПРИБОР, КОГДА ЕСТЬ СЛЕПОЕ ЧТЕНИЕ. Ранговый прибор при наших размерах безнадёжен:
эффект в ОДНО место стоит 40 глав Гу на китайской паре и 68 на английской (мощностная таблица
консилиума 22.08). Панель-0 — 7.8 главы Гу, её MDE ≈ 2 места. ⇒ ранговый эндпойнт этой панели
объявлен ОПИСАТЕЛЬНЫМ ЗАРАНЕЕ, а несущим становится СЧЁТНЫЙ: дефект-класс, ломающийся в 2030%
глав при ~0 у компаратора, ловится уже на 2530 единицах.
⚠ И ЭТО ЖЕ ДОЛГ ШАГА 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 л. три и все мужские. ⇒ местоимение обязано стоять в
# синтаксической связи: сразу перед глаголом (через 03 служебных слова) или сразу после него.
_ADV = (r"(?:не|уже|бы|тоже|также|всё|ещё|лишь|только|чуть|едва|снова|вновь|даже|так|там|тут|"
r"потом|тогда|сразу|вдруг|наконец|почти|словно|будто|нехотя|молча|тихо|быстро)\s+")
_FORM = r"[а-яё]+л(?:ась|ся|а)?"
_RE_ROD = (re.compile(rf"\\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")