textmachine/eval/dovodka/tier2.py

348 lines
22 KiB
Python
Raw Permalink 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
"""ПЕРЕ-СУДЕЙСТВО ПРОХОДА `tier` ПОЧИНЕННЫМ ПРИБОРОМ. $0 (агент-сессии).
Заказано владельцем 15.08: «Можно пересудить. Но не подгонять. Нужно понять, кто ошибается из
судей». Пере-судятся ТЕ ЖЕ ТЕКСТЫ — ни одна клетка не покупается заново, поэтому разница между
старым и новым замером есть разница ПРИБОРА, а не материала.
⚠ ЧТО ИМЕННО СЛОМАНО В СТАРОМ ЗАМЕРЕ (замерено, не предположено — `gain.py`):
· пачка легла на пол шкалы: 38% клеток ровно ноль, из них 24% с ПУСТЫМ обоснованием; у арма `T2`
нулей 75%, у боевого `R0` — 56%; в решающем контрасте 9 ничьих из 16, 7 из них на нуле;
· тот же арм `R0` на тех же 16 единицах в проходе `border` дал 4.31 ошибки против 0.69 здесь
(подписи текста совпадают 16/16) — прибор читал те же байты вшестеро глуше;
· семейства не коррелируют по трудности единицы (r=0.22) при +0.31…+0.70 на осях фазы Д;
· разобранная руками единица `38c99e5efb`: боевой редактор превратил адресата угрозы в союзника
(«мы с тобой покараем его» против 我二人…除了你), судья поставил 0 с пустым обоснованием.
⚠ ТРИ ПРАВКИ ПАНЕЛИ — ПРЕ-РЕГИСТРИРУЮТСЯ ЗДЕСЬ, ДО ПЕРВОГО ОТВЕТА:
1. `CTRLfloorA` УБРАН как отдельная метка: он побайтно равен боевому арму `T1` в 16/16, то есть
один текст лежал в пачке дважды. Пол считается как и раньше — по РАЗНОСТИ двух генераций
того же лечения, но вторая метка теперь одна (`CTRLfloor` = прежний `CTRLfloorB`), а первой
служит сам `T1`. Дублей в пачке не остаётся.
2. `CTRLmargin` ДОБАВЛЕН — маржевый декой (норма П-2), которого у прохода `tier` не было никогда.
Без него «не различимо» неотличимо от слепоты прибора; именно этого контроля не хватило паку 23.
3. Ключ ВЫПУСКАЕТСЯ НОВЫЙ (`tier2-KEY.json`, своя соль), старый не трогается. Раскладка меток —
функция соли, uid и НАБОРА армов; эмиссия поверх старого ключа переписала бы отсуженную
раскладку (авария фазы Д, хендофф §5 п.1).
⚠ ТРИ ПРАВКИ ЗАДАНИЯ — ТОЖЕ ПРЕ-РЕГИСТРИРУЮТСЯ. Лечится ЗАДАНИЕМ, а не сменой судьи: прибор
устойчив (побайтно один текст под двумя метками дал sd=0.000 по всем осям 16/16), у него высокий
порог счёта, а не разболтанность.
A. МОЛЧАЛИВЫЙ НОЛЬ ЗАПРЕЩЁН: клетка с нулём обязана нести непустое «почему».
B. В рубрику ВЕРНОСТЬ внесены КЛАССЫ, которые старая пачка пропускала: инверсия адресата или
участника реплики · потеря/подмена разряда, ранга, числа · состояние идиомы · снятое или
добавленное отрицание. Классы арм-нейтральны и ни на один арм не указывают.
C. Сказано прямо, что ноль — утверждение, а не отсутствие ответа.
Всё прочее в задании побайтно то же, что судили агенты пака 23.
⚠ ПРАВИЛО РЕШЕНИЯ — ОБЪЯВЛЯЕТСЯ ДО ОТВЕТОВ:
· пачка, не взявшая гейт плотности (`gain.py`: доля нулей ≥25%), НЕ несёт вывода «не различимо»;
· семейство, чей перевес на маржевом декое не пересекает свой порог, теряет право говорить
«не хуже» (П-2);
· контрасты `T1/R0`, `T2/R0`, `T3/R0` — точный знаковый тест с поправкой Холма по трём;
`R0/F` — позитивный контроль, в семейство не входит;
· порог различимости = 2.8·sd/√n по разностям пар пола, как во всей фазе;
· сравнение со старым замером печатается по-армно и является ГЛАВНЫМ результатом прогона:
вопрос стоит не «кто лучше», а «что показывает прибор, когда ему запрещено молчать».
Запуск: tier2.py --emit | --score | --selftest
"""
from __future__ import annotations
import hashlib
import importlib.util
import json
import re
import statistics as st
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))
ET = Path.home() / "books" / "editor-tier"
OLD_KEY = ET / "blind-keys" / "tier-KEY.json"
OLD_TASKS, OLD_ANSW = ET / "aj-tier-tasks", ET / "aj-tier"
NEW_KEY = ET / "blind-keys" / "tier2-KEY.json"
SALT_F = ET / "blind-keys" / "SALT-tier2.txt"
TASKS, ANSW = ET / "tier2-tasks", ET / "tier2"
AX = ("ВЕРНОСТЬ", "ТЕРМИН", "ЯЗЫК", "ФОРМА")
CARRY = ("ВЕРНОСТЬ", "ЯЗЫК")
ARMS = ("R0", "T1", "T2", "T3", "F") # боевые; контраст — T*/R0, контроль — R0/F
CTRL = ("CTRLdecoy", "CTRLfloor", "CTRLmargin")
PER_SESSION = 4 # заданий на сессию, как гонялся `border`
SALT = "tier2-2026-08-15" # своя соль; старая не трогается
_axre = re.compile(r"^[ТT](\d+)-(ВЕРНОСТЬ|ТЕРМИН|ЯЗЫК|ФОРМА):\s*(\d+)", re.MULTILINE)
_whyre = re.compile(r"^[ТT](\d+)-ПОЧЕМУ:(.*)$", re.MULTILINE)
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
def _sud():
"""`sud` грузится лениво: он разбирает флаги на уровне модуля и тянет сырьё своей оси."""
return sys.modules.get("sud") or _load("sud", ZONE / "sud.py")
def old_texts() -> dict[str, dict[str, str]]:
"""(uid → {арм: текст}) из ОТСУЖЕННЫХ заданий пака 23. Тексты не покупаются заново."""
key = json.loads(OLD_KEY.read_text(encoding="utf-8"))
out: dict[str, dict[str, str]] = {}
for tok, km in key.items():
if not isinstance(km, dict):
continue
f = OLD_TASKS / f"{tok}.txt"
if not f.exists():
continue
body = f.read_text(encoding="utf-8")
# варианты разделены строками вида «Т3:»; исходник идёт до первой такой строки
parts = re.split(r"^(Т\d):\s*$", body, flags=re.MULTILINE)
src = re.search(r"^ИСХОДНИК:\s*\n(.*?)(?=\n=+\n|\Z)", parts[0], re.DOTALL | re.MULTILINE)
texts = {}
for i in range(1, len(parts) - 1, 2):
texts[parts[i]] = parts[i + 1].strip()
for lab, meta in km.items():
if not isinstance(meta, dict) or lab not in texts:
continue
uid = meta["uid"]
out.setdefault(uid, {})["_src"] = (src.group(1).strip() if src else "")
out[uid][meta["arm"]] = texts[lab]
return out
def layout(uid: str, arms: tuple[str, ...]) -> dict[str, str]:
"""Метка → арм. Чистая функция от соли, uid и НАБОРА армов — как во всей дуге."""
order = sorted(arms)
seed = hashlib.sha256(f"{SALT}|{uid}|{'|'.join(order)}".encode()).hexdigest()
rank = sorted(order, key=lambda a: hashlib.sha256(f"{seed}|{a}".encode()).hexdigest())
return {f"Т{i}": a for i, a in enumerate(rank, 1)}
HEAD = """Ты — литературный редактор-эксперт. Тебе дан ИСХОДНИК на китайском и {n} независимых русских перевода этого же фрагмента под слепыми метками.
Посчитай ОШИБКИ в каждом варианте по типам:
ВЕРНОСТЬ — сказано не то, что в исходнике: искажение, инверсия, выдумка, пропуск факта.
Сюда же входят, и их пропускают чаще всего:
· инверсия адресата или участника реплики (кто кому говорит, кто кого бьёт);
· потеря или подмена разряда, ранга, числа, единицы измерения;
· состояние идиомы или действия: совершилось против ещё не совершилось;
· снятое или, наоборот, добавленное отрицание.
ТЕРМИН — имя или термин передан не так, как в других местах того же текста.
ЯЗЫК — то, чего носитель не напишет: калька, канцелярит, несуществующее слово, кривой
порядок слов, рассогласование.
ФОРМА — вёрстка и пунктуация: оформление реплик, кавычки, тире, абзацы.
Оцени КАЖДЫЙ вариант ОТДЕЛЬНО против исходника — не сравнивая варианты между
собой. Порядок вариантов ничего не значит и специально перемешан.
⚠ НОЛЬ — ЭТО УТВЕРЖДЕНИЕ, А НЕ ОТСУТСТВИЕ ОТВЕТА. Ноль означает «я сверил этот вариант с
исходником по этой оси и дефектов не нашёл». Поэтому ПОЧЕМУ обязательно для КАЖДОЙ метки, в том
числе при нулях: если по всем осям ноль, напиши «прочитано, дефектов не найдено». Пустое ПОЧЕМУ
не принимается и на диск не попадает.
Ответ ЗАПИШИ ФАЙЛОМ в {path} — ровно в таком виде и ничем больше, блок на каждую метку:
{skeleton}
ПОЧЕМУ для НЕНУЛЕВОЙ оси: короткая ЦИТАТА из текста через « | ».
Число без цитаты не принимается и на диск не попадает: именно из-за отсутствия цитат показания
прошлых судей оказались непроверяемыми.
"""
def emit() -> None:
S = _sud()
src = old_texts()
TASKS.mkdir(parents=True, exist_ok=True)
ANSW.mkdir(parents=True, exist_ok=True)
NEW_KEY.parent.mkdir(parents=True, exist_ok=True)
SALT_F.write_text(SALT + "\n", encoding="utf-8")
key: dict[str, dict] = {
"_проход": "tier2",
"_семейство": ["T1/R0", "T2/R0", "T3/R0"],
"онтроль": ["R0/F"],
"_армы": list(ARMS) + list(CTRL),
}
made = 0
for uid, arms in sorted(src.items()):
if not all(arms.get(a, "").strip() for a in ARMS):
print(f"{uid}: не хватает армов, единица пропущена")
continue
texts = {a: arms[a] for a in ARMS}
texts["CTRLdecoy"] = arms.get("CTRLdecoy", "")
texts["CTRLfloor"] = arms.get("CTRLfloorB", "")
planted, n_pl = S.margin_plant(arms["R0"])
texts["CTRLmargin"] = planted if n_pl >= 2 else ""
panel = tuple(a for a in (*ARMS, *CTRL) if texts.get(a, "").strip())
lay = layout(uid, panel)
tok = hashlib.sha256(f"{SALT}|{uid}".encode()).hexdigest()[:10]
skel = "\n\n".join("\n".join(f"{lab}-{a}: <число>" for a in AX) + f"\n{lab}-ПОЧЕМУ: <цитаты>"
for lab in lay)
body = [HEAD.format(n=len(lay), path=ANSW / f"{tok}.txt", skeleton=skel),
"", "=" * 60, "ИСХОДНИК:", arms.get("_src", "")]
for lab, arm in lay.items():
body += ["", "=" * 60, f"{lab}:", texts[arm]]
(TASKS / f"{tok}.txt").write_text("\n".join(body), encoding="utf-8")
key[tok] = {lab: {"arm": a, "uid": uid,
"kind": "контроль" if a.startswith("CTRL") else "боевой",
"sig": hashlib.sha256(texts[a].encode()).hexdigest()[:12]}
for lab, a in lay.items()}
made += 1
NEW_KEY.write_text(json.dumps(key, ensure_ascii=False, indent=1), encoding="utf-8")
toks = [t for t in key if not t.startswith("_")]
print(f"заданий {made}{TASKS}")
print(f"ключ → {NEW_KEY} (судье НЕ давать); соль → {SALT_F}")
print(f"сессий по {PER_SESSION} задания: {-(-len(toks) // PER_SESSION)}")
for i in range(0, len(toks), PER_SESSION):
print(f" сессия {i // PER_SESSION + 1}: " + " · ".join(toks[i:i + PER_SESSION]))
def read(dirpath: Path, keypath: Path) -> tuple[dict, list]:
"""{(uid, арм): сумма несущих осей} + список замечаний годности."""
key = json.loads(keypath.read_text(encoding="utf-8"))
out, bad = {}, []
for f in sorted(dirpath.glob("*.txt")):
km = key.get(f.stem)
if not isinstance(km, dict):
continue
t = f.read_text(encoding="utf-8", errors="replace")
cells: dict[str, dict[str, int]] = {}
for m in _axre.finditer(t):
cells.setdefault("Т" + m.group(1), {})[m.group(2)] = int(m.group(3))
why = {"Т" + m.group(1): m.group(2).strip() for m in _whyre.finditer(t)}
for lab, ax in cells.items():
meta = km.get(lab)
if not isinstance(meta, dict):
continue
if len(ax) < len(AX):
bad.append(f"{f.stem}/{lab}: не все четыре оси")
continue
if not why.get(lab):
bad.append(f"{f.stem}/{lab}: ПУСТОЕ обоснование (запрещено заданием)")
out[(meta["uid"], meta["arm"])] = sum(ax.get(a, 0) for a in CARRY)
return out, bad
def _sign_p(diffs: list[float]) -> float:
"""Точный двусторонний знаковый тест; ничьи отбрасываются."""
import math
nz = [d for d in diffs if d != 0]
n = len(nz)
if not n:
return 1.0
k = sum(1 for d in nz if d > 0)
c = lambda a, b: math.comb(a, b)
tail = sum(c(n, i) for i in range(min(k, n - k) + 1)) / 2 ** n
return min(1.0, 2 * tail)
def score() -> int:
if not NEW_KEY.exists():
print("ключа нет — сначала --emit")
return 1
new, bad = read(ANSW, NEW_KEY)
old, _ = read(OLD_ANSW, OLD_KEY)
if not new:
print(f"ответов в {ANSW} нет")
return 1
uids = sorted({u for u, _ in new})
print(f"единиц отсужено: {len(uids)} · клеток {len(new)} · замечаний годности {len(bad)}")
for b in bad[:8]:
print("", b)
vals = [v for (u, a), v in new.items() if not a.startswith("CTRL")]
zeros = sum(1 for v in vals if v == 0) / max(len(vals), 1)
print(f"\nГЕЙТ ПЛОТНОСТИ: ошибок/клетку {st.mean(vals):.2f} · доля нулей {zeros:.0%} "
f"{'ГОДНО' if zeros < 0.25 else '⛔ ПОЛ ШКАЛЫ, вывод «не различимо» не принимается'}")
dec = [new[(u, "CTRLdecoy")] - new[(u, "R0")] for u in uids
if (u, "CTRLdecoy") in new and (u, "R0") in new]
print(f"ГРУБЫЙ ДЕКОЙ: пойман {sum(1 for d in dec if d > 0)}/{len(dec)} · медиана +{st.median(dec) if dec else 0:.1f}")
fl = [new[(u, "T1")] - new[(u, "CTRLfloor")] for u in uids
if (u, "T1") in new and (u, "CTRLfloor") in new]
sd = st.pstdev(fl) if len(fl) > 1 else 0.0
thr = 2.8 * sd / max(len(fl), 1) ** 0.5 if fl else 0.0
print(f"СВОЙ ПОЛ: пар {len(fl)} · sd {sd:.2f} → ПОРОГ РАЗЛИЧИМОСТИ {thr:.2f}")
mg = [new[(u, "CTRLmargin")] - new[(u, "R0")] for u in uids
if (u, "CTRLmargin") in new and (u, "R0") in new]
mgm = st.mean(mg) if mg else 0.0
ok_m = mgm > thr
print(f"МАРЖЕВЫЙ ДЕКОЙ (гейт чувствительности П-2): n={len(mg)} среднее {mgm:+.2f} "
f"против порога {thr:.2f}{'ПРОЙДЕН' if ok_m else 'НЕ ПРОЙДЕН — права на «не хуже» нет'}")
print(f"\n{'контраст':12s}{'ед.':>5s}{'перевес':>9s}{'p':>9s}{'p Холма':>10s} вердикт")
ps = {}
for a in ("T1", "T2", "T3"):
d = [new[(u, "R0")] - new[(u, a)] for u in uids if (u, a) in new and (u, "R0") in new]
if d:
ps[a] = (_sign_p(d), st.mean(d), len(d))
for i, (a, (p, m, n)) in enumerate(sorted(ps.items(), key=lambda x: x[1][0])):
holm = min(1.0, p * (len(ps) - i))
v = "ПЕРЕШЁЛ ПОРОГ" if abs(m) > thr and holm < 0.05 else "ниже порога"
print(f"{a + ' vs R0':12s}{n:5d}{m:+9.2f}{p:9.4f}{holm:10.4f} {v}")
d = [new[(u, "R0")] - new[(u, "F")] for u in uids if (u, "F") in new and (u, "R0") in new]
if d:
print(f"{'R0 vs F':12s}{len(d):5d}{st.mean(d):+9.2f}{_sign_p(d):9.4f}{'':>10s} позитивный контроль")
print(f"\n{'арм':12s}{'СТАРЫЙ замер':>14s}{'НОВЫЙ замер':>13s}{'сдвиг':>8s} ← главный результат")
for a in (*ARMS, *CTRL):
o = [old[(u, a)] for u in uids if (u, a) in old]
n_ = [new[(u, a)] for u in uids if (u, a) in new]
if not n_:
continue
om = st.mean(o) if o else float("nan")
print(f"{a:12s}{om:14.2f}{st.mean(n_):13.2f}{st.mean(n_) - om:+8.2f}")
print("\n⚠ Тексты не менялись ни в одном арме. Любой сдвиг здесь — свойство ПРИБОРА.")
return 0
def selftest() -> int:
fails = []
ok = lambda c, m: fails.append(m) if not c else None
src = old_texts()
ok(len(src) == 16, f"старых единиц разобрано {len(src)}, ожидалось 16")
if src:
u0 = next(iter(src))
ok(all(src[u0].get(a, "").strip() for a in ARMS), f"{u0}: не все боевые армы извлеклись")
ok(len(src[u0].get("_src", "")) > 200, f"{u0}: исходник не извлёкся")
# дублей в новой панели быть не должно
S = _sud()
t = {a: src[u0][a] for a in ARMS}
t["CTRLfloor"] = src[u0].get("CTRLfloorB", "")
t["CTRLmargin"] = S.margin_plant(src[u0]["R0"])[0]
sigs = [hashlib.sha256(v.encode()).hexdigest() for v in t.values() if v.strip()]
ok(len(sigs) == len(set(sigs)), f"{u0}: в новой панели остались побайтные дубли")
# старая панель дубли содержала — проверка, что мы чиним реальную болезнь
old_pair = src[u0].get("CTRLfloorA", "") == src[u0].get("T1", "")
ok(old_pair, f"{u0}: ожидалось, что старый CTRLfloorA ≡ T1 (дефект пака 23)")
lay = layout(u0, tuple(a for a in (*ARMS, *CTRL) if t.get(a, "").strip()))
ok(len(lay) == len(set(lay.values())), "раскладка меток не биективна")
ok(layout(u0, tuple(sorted(lay.values()))) == lay, "раскладка не воспроизводима")
ok(_sign_p([1, 1, 1, 1, 1]) < 0.07, "знаковый тест: пять единиц в одну сторону должны дать p<0.07")
ok(_sign_p([0, 0, 0]) == 1.0, "знаковый тест: одни ничьи должны дать p=1")
for m in fails:
print("ПРОВАЛ:", m)
print(f"селфтест tier2: провалов {len(fails)}")
return 1 if fails else 0
if __name__ == "__main__":
a = sys.argv[1:] or ["--selftest"]
if a[0] == "--emit":
emit()
elif a[0] == "--score":
sys.exit(score())
elif a[0] == "--selftest":
sys.exit(selftest())
else:
raise SystemExit(f"⛔ неизвестный режим {a[0]!r}")