348 lines
22 KiB
Python
348 lines
22 KiB
Python
#!/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}")
|