#!/usr/bin/env python3 """Ф0.4 детектор №2, ШАГ 3 — МОЩНОСТЬ парного QE на классе «инверсия полярности». Что осталось неотвеченным после шага 2. На XL парная Δ вышла положительной по медиане в обоих классах (+0.981 и +1.147) с направлением 4/6 и 5/6. Соблазн прочитать это как «на сравнении версий ранкер работает» — но n=6, и знаковый тест при 5/6 даёт односторонний p≈0.11. Клейм НЕ УСТАНОВЛЕН, и объявлять его установленным на шести точках — ровно тот антипаттерн, который эксп-19 уже допустил однажды (правило «диапазоны не пересекаются» при n=3, отозвано адверсариальным ревью). Почему вопрос стоит того, чтобы добрать мощность. Он разводит два РАЗНЫХ применения ранкера: арм C ищет дефект в ОДНОМ тексте, образца для сравнения нет ⇒ нужен АБСОЛЮТНЫЙ порог. Шаг 2 показал, что порога нет: на обеих моделях лучшая precision ≤0.17. арм G сравнивает ДВЕ версии одного сегмента (guarded APE, «принимать правку, только если внешний гейт видит улучшение») ⇒ нужна ПАРНАЯ дельта, и только она. Если парная дельта состоятельна, арм G питаем, а арм C — нет. Это не оттенок, а разная судьба двух армов фазы 2б, поэтому вопрос решается замером, а не осторожной формулировкой. Как добирается n. Ручных посадок больше не станет — их делала другая сессия под другую задачу. Зато ИНВЕРСИЯ ПОЛЯРНОСТИ порождается механически и остаётся тем же классом дефекта: снятие или вставка отрицания при глаголе меняет смысл на противоположный, сохраняя грамматичность. По 81 здоровому сегменту строится столько пар, сколько найдётся глаголов, и каждая судится парно. ⚠ ГРАНИЦА, объявленная заранее: механическая инверсия ЛЕГЧЕ ручной посадки. Голое «не» — сильный поверхностный сигнал, а посадки пробы 18 меняли смысл переписыванием оборота («не выдержат» → «легко выдержат»). Поэтому результат читается как ВЕРХНЯЯ ГРАНИЦА парного режима: если ранкер не берёт даже это, вопрос закрыт отрицательно; если берёт — это ещё не значит, что возьмёт тонкую инверсию, и ручные 6 точек остаются единственной оценкой на реальном классе. Запуск (офлайн, $0): ~/.venvs/tm-qe/bin/python eval/role_topology/qe_power.py # large METRICX_MODEL=google/metricx-24-hybrid-XL-v2p6 METRICX_TOKENIZER=google/mt5-xl \ ~/.venvs/tm-qe/bin/python eval/role_topology/qe_power.py # XL """ from __future__ import annotations import json import os import statistics import sys from pathlib import Path REPO = Path("/home/ubuntu/projects/textmachine") sys.path.insert(0, str(REPO / "eval" / "role_topology")) sys.path.insert(0, str(REPO / "eval" / "bank_arbitration")) sys.path.insert(0, str(REPO / "eval" / "editor_contract")) sys.path.insert(0, str(REPO / "eval" / "exp16")) import pymorphy3 # noqa: E402 from align import align, flip_polarity, split_ru, split_zh # noqa: E402 from qe_bench import QE, MODEL # noqa: E402 _MORPH = pymorphy3.MorphAnalyzer() OUT = Path.home() / "books" / "role-topology" def sign_test_p(successes: int, n: int) -> float: """Односторонний p знакового теста при H0: направление случайно (q=0.5). Считается точно через биномиальные коэффициенты — scipy тут не нужен, а зависимость лишняя. """ from math import comb # noqa: PLC0415 return sum(comb(n, k) for k in range(successes, n + 1)) / (2 ** n) def main() -> None: import editor_wire_probe as P # noqa: PLC0415 from inject_probe import pick_windows # noqa: PLC0415 qe = QE() pairs_scored: list[dict] = [] for k, (_, src, _) in enumerate(pick_windows()): clean = P.draft_of(k) zs, rs = split_zh(src), split_ru(clean) for si, di in align(zs, rs): if not si or not di: continue s = " ".join(zs[i] for i in si) d = " ".join(rs[i] for i in di) bad = flip_polarity(d) if not bad or bad == d: continue base = qe.score(s, d) pairs_scored.append(dict(window=k, base=base, flipped=qe.score(s, bad), seg_len=len(d))) n = len(pairs_scored) if not n: print("инверсий не построено — проверить flip_polarity") return deltas = [r["flipped"] - r["base"] for r in pairs_scored] pos = sum(1 for x in deltas if x > 0) print(f"МОЩНОСТЬ ПАРНОГО QE на инверсии полярности — модель {MODEL}") print(f"пар построено: {n} (из 81 здорового сегмента шести окон)\n") print(f"медиана Δ {statistics.median(deltas):+.3f}") print(f"среднее Δ {statistics.mean(deltas):+.3f}") print(f"направление {pos}/{n} = {pos / n:.0%}") print(f"знаковый тест p = {sign_test_p(pos, n):.5f} (односторонний)") print(f"Δ > 1.0 {sum(1 for x in deltas if x > 1.0)}/{n}") print(f"Δ < 0 (ранкер счёл инверсию ЛУЧШЕ) {sum(1 for x in deltas if x < 0)}/{n}") # Операционный вопрос арма G: с каким порогом на ПАРНУЮ дельту он принимал бы решение и # сколько здоровых правок при этом отверг бы. Ложная тревога здесь = отказ от хорошей правки. print(f"\n{'порог Δ':>9s}{'поймано инверсий':>19s}{'доля':>8s}") print("-" * 36) for thr in (0.0, 0.25, 0.5, 1.0, 2.0): got = sum(1 for x in deltas if x > thr) print(f"{thr:9.2f}{got:19d}{got / n:8.0%}") (OUT / f"qe-power-{'xl' if 'XL' in MODEL else 'large'}.json").write_text( json.dumps(pairs_scored, ensure_ascii=False), encoding="utf-8") print("\nЧитать: это ВЕРХНЯЯ граница парного режима (механическая инверсия проще ручной).\n" "Отрицательный исход закрывает вопрос; положительный — открывает только арм G, не арм C.") if __name__ == "__main__": os.environ.setdefault("PROTOCOL_BUFFERS_PYTHON_IMPLEMENTATION", "python") main()