524 lines
41 KiB
Python
524 lines
41 KiB
Python
#!/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")
|