textmachine/eval/dovodka/chtenie.py

374 lines
23 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.
⚠ ЗАЧЕМ. Весь риг 19→23 считает ЛОКАЛЬНЫЕ ошибки внутри чанка и по построению не видит того, что
владелец назвал целью №1: победы над translationese. Пре-рег П-6 завёл под это слепое ранжирование
издательской пригодности, владелец 11.08 сказал «берём» — и оно не было построено.
Файл прошёл две редакции, и обе живут здесь, а не в двух файлах:
1. КАЛИБРОВКА 16.08 — одна единица на ось, без контролей. Читал Fable по поручению владельца.
Дала результат, ради которого всё дальнейшее и делается: на английской оси Спирмен между
счётом ошибок и читаемостью **0.10**, связи нет. Улики: `~/books/chtenie-vladeltsa/ОТВЕТ-*.md`,
ключ `~/books/dovodka/blind-keys-chtenie/`. Пере-считать: `--score-calib`.
2. ПОЛНАЯ ПАНЕЛЬ захода Д17 — выборка единиц, ДВА независимых читателя и контроли, которых у
калибровки не было:
· КОНТРОЛЬ ДЕКОЯ: намеренно испорченный вариант обязан ранжироваться последним;
· КОНТРОЛЬ ПОЛА: две генерации ОДНОГО редактора (`Z0`/`ZF`) обязаны оказаться СОСЕДЯМИ —
развёл далеко, значит порядок читателя есть шум, и «победа» внутри этого расстояния
ничего не значит. Это для чтения то же, чем шумовой пол служит счёту.
⚠ ЧЕГО ПРИБОР НЕ МОЖЕТ. Абсолютной оценки качества он не даёт и читателя-человека не заменяет:
читают модели. Он отвечает на ОДИН вопрос — совпадает ли порядок по читаемости с порядком по
счёту ошибок, — и потому решает судьбу СЧЁТА как дешёвого прокси, а не судьбу армов.
Запуск: chtenie.py --emit | --score | --selftest | --score-calib
"""
from __future__ import annotations
import collections
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))
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
ZS = _load("zsud_c", ZONE / "zsud.py")
Z = ZS.Z
WORK = Path.home() / "books" / "chtenie-z17"
KEYD = Path.home() / "books" / "dovodka" / "blind-keys-chtenie-z17"
CALIB = Path.home() / "books" / "chtenie-vladeltsa"
CALIB_KEY = Path.home() / "books" / "dovodka" / "blind-keys-chtenie" / "chtenie-KEY.json"
SALT = "chtenie-z17-2026-08-16"
N_UNITS = 8 # на ось; больше читатель за раз честно не осилит
READERS = ("A", "B") # два независимых читателя РАЗНЫХ семейств
# Единиц в задании: китайская глава ~9.7 тыс. знаков × 7 вариантов = 68 тыс. на единицу,
# английская ~1.9 тыс. × 7 = 13 тыс. Поэтому китайские читаются по одной, английские — по четыре.
PER_TASK = {"zh": 1, "en": 4}
PANEL = ("ZD", "Z0", "ZF", "ZP", "Z8R", "Z1")
DECOY = "CTRLdecoy"
HEAD = """# Слепое чтение — {pair}, задание {k}
## Что это и зачем
Наш измерительный аппарат считает ЛОКАЛЬНЫЕ ОШИБКИ: искажения смысла, кальки, сбитую вёрстку —
штука за штукой, внутри отрывка. Это дёшево и воспроизводимо, но у такого прибора есть встроенная
слепота: **текст может быть без единой ошибки и при этом мёртвый**. Серая гладкая проза получит
«претензий нет», а читатель закроет книгу.
Поэтому здесь спрашивают ЧИТАТЕЛЯ. Ниже — один и тот же отрывок в нескольких вариантах перевода,
сделанных разными способами. Способы не названы намеренно: метки перемешаны, соответствие
«метка → способ» лежит в отдельном файле и до твоего ответа не открывается.
Твой ответ решает не «какой способ лучше» — он решает, **совпадает ли человеческий порядок с тем,
что печатает наш счётчик ошибок**. Совпал — счёт можно и дальше использовать как дешёвую замену
чтению. Разошёлся — значит все выводы мерили не то.
## Как читать
Читай как ЧИТАТЕЛЬ, а не как корректор. Вопрос один: **какой текст ты взял бы в книгу**.
Главный критерий — живая русская проза против переводного канцелярита. Смысловые ошибки, если
заметишь, называй, но ранжируй по читаемости.
⚠ **Три вещи, которые могут тебя сбить, — говорю заранее.**
1. **Вёрстка выдаёт метод.** Часть вариантов прошла полное переписывание, а оно обязано сливать
построчный исходник в русские абзацы; остальные вёрстку исходника сохраняют. Разницу видно ДО
чтения — не выводи из неё, «какой это способ», и не давай ей решать за тебя.
2. **Не угадывай гипотезы.** Не пытайся понять, каким способом сделан вариант, и не проверяй по
ходу, совпало ли с ожиданием. Запиши порядок ДО любых рассуждений о методах.
3. **Похожие варианты — это нормально.** Часть вариантов может оказаться очень близка друг к
другу. Не различаешь — так и напиши через «=», это законный и ценный ответ.
## Что записать
Ответ — в файл `{answer}`. На КАЖДЫЙ отрывок обязательно две вещи:
1. Строка `ПОРЯДОК <номер отрывка>: Т3 > Т1 > Т5 > Т2 > Т4 > Т6 > Т7` — от лучшего к худшему.
Неразличимые варианты соединяй знаком `=`.
2. По одной-две фразы на вариант: почему. Годится и «серо, но гладко», и «живой ритм, но во
втором абзаце потерян смысл».
Если какой-то вариант покажется откровенно машинным — скажи прямо, это самая ценная информация.
## Границы
* Не открывай на диске ничего, кроме этого задания, и не пиши никуда, кроме указанного файла.
* Не выясняй происхождение вариантов; не применяй `diff`, `difflib`, `cmp` и механическое сличение.
* Не запускай своих агентов: сессия — это ты один.
* Не переводи сам и не предлагай свою версию.
"""
def pick(pair: str) -> list[str]:
"""N_UNITS единиц из ОТСУЖЕННОГО набора — чтобы порядки читателя и счётчика сравнивались
на одних и тех же текстах. Отбор детерминирован своей солью."""
uids = sorted(u["uid"] for u in Z.chosen(pair))
return sorted(uids, key=lambda u: hashlib.sha256(f"{SALT}|{pair}|{u}".encode()).hexdigest()
)[:N_UNITS]
def variants(pair: str, tx: dict[str, str]) -> dict[str, str]:
"""⚠ ПОЛНАЯ ПАНЕЛЬ ИЛИ НИЧЕГО (починка по ревью). Прежняя редакция отдавала читателю столько
вариантов, сколько куплено, — а средние ранги, снятые на пятёрке и на восьмёрке вариантов,
несравнимы между собой (ранг 1..5 против 1..8). Неполная единица просто не эмитируется."""
if not all(tx.get(a, "").strip() for a in PANEL):
return {}
out = {a: tx[a] for a in PANEL}
dec, n = ZS.AJ._plant(tx.get("Z0", ""))
if n >= ZS.MARGIN_MIN:
out[DECOY] = dec
return out
def layout(pair: str, uid: str, reader: str, arms) -> dict[str, str]:
"""Своя раскладка КАЖДОМУ читателю: одинаковая давала бы им общий позиционный наклон."""
order = sorted(arms)
seed = hashlib.sha256(f"{SALT}|{pair}|{uid}|{reader}|{'|'.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)}
def emit() -> None:
key: dict = {"_что": "слепое чтение по полной панели захода Д17 (П-6)", "_соль": SALT,
"онтроли": ["декой обязан быть последним", "Z0 и ZF обязаны быть соседями"]}
made = 0
for pair in Z.PAIRS:
tx_all = ZS.texts_of(pair)
uids = [u for u in pick(pair) if variants(pair, tx_all[u])]
for reader in READERS:
d = WORK / pair / reader
(d / "tasks").mkdir(parents=True, exist_ok=True)
(d / "answers").mkdir(parents=True, exist_ok=True)
step = PER_TASK[pair]
for k in range(0, len(uids), step):
grp = uids[k:k + step]
tok = hashlib.sha256(f"{SALT}|{pair}|{reader}|{grp[0]}".encode()).hexdigest()[:10]
ans = d / "answers" / f"{tok}.md"
body = [HEAD.format(pair=("китайская ось" if pair == "zh" else "английская ось"),
k=k // step + 1, answer=ans)]
for j, uid in enumerate(grp, 1):
vs = variants(pair, tx_all[uid])
lay = layout(pair, uid, reader, tuple(vs))
body += ["", "=" * 70, f"## ОТРЫВОК {j} — ИСХОДНИК", "",
tx_all[uid]["_src"], ""]
for lab, arm in lay.items():
body += ["-" * 70, f"### ОТРЫВОК {j}, вариант {lab}", "", vs[arm], ""]
key.setdefault(tok, {})[str(j)] = {
"uid": uid, "pair": pair, "reader": reader, "метки": lay,
"знаков": {a: len(t) for a, t in vs.items()}}
(d / "tasks" / f"{tok}.md").write_text("\n".join(body), encoding="utf-8")
made += 1
KEYD.mkdir(parents=True, exist_ok=True)
(KEYD / "chtenie-z17-KEY.json").write_text(json.dumps(key, ensure_ascii=False, indent=1),
encoding="utf-8")
print(f"заданий {made}{WORK}/<пара>/<читатель>/tasks")
print(f"ключ → {KEYD} (читателю НЕ давать)")
# ⚠ Латинская `T` принимается наравне с кириллической `Т` (починка по ревью): судьи уже писали
# латиницей, а `zsud`/`ja6` это учитывают. Молча выпавший ответ читателя стоил бы целой сессии.
_ORD = re.compile(r"ПОРЯДОК\s*(\d+)\s*:\s*((?:[ТT]\d+\s*[>=]\s*)+[ТT]\d+)", re.IGNORECASE)
def _ranks(order: str) -> dict[str, float]:
"""Метка → ранг (1 = лучший). Связанные через `=` получают средний ранг."""
out, pos = {}, 1
for g in [x.strip() for x in re.split(r">", order)]:
labs = [x.replace("T", "Т") for x in re.findall(r"[ТT]\d+", g)]
for x in labs:
out[x] = pos + (len(labs) - 1) / 2
pos += len(labs)
return out
def _rank(v: list[float]) -> list[float]:
"""Ранги со средним для связок — как `scipy.rankdata`, сверено с ним."""
order = sorted(range(len(v)), key=lambda i: v[i])
out = [0.0] * len(v)
i = 0
while i < len(order):
j = i
while j + 1 < len(order) and v[order[j + 1]] == v[order[i]]:
j += 1
r = (i + j) / 2 + 1
for k in range(i, j + 1):
out[order[k]] = r
i = j + 1
return out
def _spearman(a: list[float], b: list[float]) -> float:
"""⚠ ПОЧИНКА ПО РЕВЬЮ: прежняя редакция называлась Спирменом, а считала ПИРСОНА по входам.
Там, где на вход шли СРЕДНИЕ ранги и СРЕДНИЕ счёты ошибок (решающий гейт «следит ли счёт за
читаемостью»), это давало другое число: на демо-входах своё +0.79 против настоящего +1.00.
Гейт `rho > 0.5` — по пре-регу Д17 решающий эндпойнт, и он не имеет права быть приблизительным.
"""
if len(a) < 3:
return 0.0
a, b = _rank(a), _rank(b)
ma, mb = st.mean(a), st.mean(b)
num = sum((x - ma) * (y - mb) for x, y in zip(a, b))
da = sum((x - ma) ** 2 for x in a) ** 0.5
db = sum((y - mb) ** 2 for y in b) ** 0.5
return num / (da * db) if da and db else 0.0
def score() -> int:
kf = KEYD / "chtenie-z17-KEY.json"
if not kf.exists():
print("ключа нет — сначала --emit")
return 1
key = json.loads(kf.read_text(encoding="utf-8"))
sc, _bad = ZS.read()
ranks: dict[tuple, dict[str, float]] = {}
for tok, blocks in key.items():
if tok.startswith("_"):
continue
meta0 = next(iter(blocks.values()))
f = WORK / meta0["pair"] / meta0["reader"] / "answers" / f"{tok}.md"
if not f.exists():
continue
got = {m.group(1): m.group(2) for m in _ORD.finditer(
f.read_text(encoding="utf-8", errors="replace"))}
for j, meta in blocks.items():
if j not in got:
print(f"{tok}/отрывок {j}: порядка нет")
continue
r = _ranks(got[j])
ranks[(meta["pair"], meta["uid"], meta["reader"])] = {
arm: r[lab] for lab, arm in meta["метки"].items() if lab in r}
if not ranks:
print("ответов читателей нет")
return 1
rc = 0
for pair in Z.PAIRS:
rows = {k: v for k, v in ranks.items() if k[0] == pair}
if not rows:
continue
print(f"\n=== ПАРА {pair} · прочитано единиц-читателей {len(rows)} ===")
bad_d = [k for k, v in rows.items() if DECOY in v and v[DECOY] < max(v.values())]
have_d = sum(1 for v in rows.values() if DECOY in v)
print(f"КОНТРОЛЬ ДЕКОЯ: не последним в {len(bad_d)} чтениях из {have_d}"
f"{'ГОДНО' if not bad_d else '⛔ читатель невнимателен'}")
rc |= bool(bad_d)
gaps = [abs(v["Z0"] - v["ZF"]) for v in rows.values() if "Z0" in v and "ZF" in v]
if gaps:
far = sum(1 for g in gaps if g > 2)
print(f"КОНТРОЛЬ ПОЛА (две генерации одного редактора): медиана расстояния "
f"{st.median(gaps):.1f} позиции · развёл дальше двух в {far} из {len(gaps)}"
f"{'ГОДНО' if far <= len(gaps) // 4 else '⛔ порядок читателя = шум'}")
rc |= far > len(gaps) // 4
per_reader = collections.defaultdict(dict)
for (_p, uid, rd), v in rows.items():
per_reader[rd][uid] = v
if len(per_reader) == 2:
(ra, va), (rb, vb) = sorted(per_reader.items())
rr = [_spearman([va[u][x] for x in va[u] if x in vb[u]],
[vb[u][x] for x in va[u] if x in vb[u]]) for u in va if u in vb]
if rr:
print(f"СОГЛАСИЕ ЧИТАТЕЛЕЙ {ra}/{rb}: Спирмен по единицам {st.mean(rr):+.2f} "
f"(единиц {len(rr)})")
avg = {a: st.mean([v[a] for v in rows.values() if a in v])
for a in PANEL + (DECOY,) if any(a in v for v in rows.values())}
print(f"\n{'арм':12s}{'ранг чтения':>13s}{'ошибок/ед.':>12s}")
xy = []
for a in sorted(avg, key=lambda x: avg[x]):
errs = [ZS._carry(ax) for (pp, u, aa, _h), ax in sc.items()
if pp == pair and aa == a and u in {k[1] for k in rows}]
print(f"{a:12s}{avg[a]:13.2f}"
+ (f"{st.mean(errs):12.2f}" if errs else f"{'':>12s}"))
if errs and not a.startswith("CTRL"):
xy.append((avg[a], st.mean(errs)))
if len(xy) >= 3:
rho = _spearman([x for x, _ in xy], [y for _, y in xy])
print(f"\nСПИРМЕН «ранг чтения против счёта ошибок»: {rho:+.2f}"
+ ("счёт следит за читаемостью, прокси годен" if rho > 0.5 else
"⛔ СЧЁТ НЕ СЛЕДИТ ЗА ЧИТАЕМОСТЬЮ: по пре-регу Д17 побеждает ЧТЕНИЕ"))
print("\n⚠ Читают модели, не человек. Прибор отвечает на вопрос «годен ли счёт как прокси», "
"а не «какой арм лучше вообще».")
return rc
def score_calib() -> int:
"""Де-слепление КАЛИБРОВКИ 16.08: печатает, какой арм стоял за какой меткой в ответе
читателя.
⚠ ЧЕСТНАЯ ГРАНИЦА (правка по ревью): режим НЕ пере-считывает корреляцию 0.10 — он
печатает только порядок читателя. Прежний докстринг обещал воспроизводимость этого
числа, и обещание было ложным. Само число проверено независимо (адверсариальное ревью
16.08 пере-считало его по рангам из ключа и панели Д3 — вышло ровно 0.10), но КОМАНДЫ,
делающей это одним вызовом, нет. Долг назван, а не замаскирован."""
if not CALIB_KEY.exists():
print("ключа калибровки нет")
return 1
key = json.loads(CALIB_KEY.read_text(encoding="utf-8"))
for axis in ("zh", "en"):
f = CALIB / f"ОТВЕТ-{axis}.md"
if not f.exists() or axis not in key:
print(f"{axis}: ответа нет")
continue
m = re.search(r"((?:Т\d\s*[>=]\s*)+Т\d)", f.read_text(encoding="utf-8"))
if not m:
print(f"{axis}: порядок не найден")
continue
lay = key[axis]["метки"]
print(f"\n{axis}: единица {key[axis]['uid']} · порядок читателя (лучший → худший):")
for i, lab in enumerate(re.findall(r"Т\d", m.group(1)), 1):
print(f" {i}. {lab} = {lay.get(lab, '?')}")
return 0
def selftest() -> int:
fails = []
ok = lambda c, m: fails.append(m) if not c else None # noqa: E731
r = _ranks("Т3 > Т1 = Т5 > Т2")
ok(r == {"Т3": 1.0, "Т1": 2.5, "Т5": 2.5, "Т2": 4.0}, f"разбор порядка сломан: {r}")
ok(abs(_spearman([1, 2, 3], [1, 2, 3]) - 1.0) < 1e-9, "Спирмен не даёт +1 на совпадении")
ok(abs(_spearman([1, 2, 3], [3, 2, 1]) + 1.0) < 1e-9, "Спирмен не даёт 1 на инверсии")
m = _ORD.search("ПОРЯДОК 2: Т3 > Т1 > Т5")
ok(bool(m) and m.group(1) == "2", "строка порядка не находится")
for pair in Z.PAIRS:
u = pick(pair)
ok(len(u) == N_UNITS, f"{pair}: единиц {len(u)}, ожидалось {N_UNITS}")
ok(set(u) <= {x["uid"] for x in Z.chosen(pair)},
f"{pair}: единицы чтения не подмножество отсуженных — Спирмен считать не на чем")
la, lb = layout(pair, u[0], "A", PANEL), layout(pair, u[0], "B", PANEL)
ok(la != lb, f"{pair}: у читателей ОДНА раскладка — общий позиционный наклон")
ok(layout(pair, u[0], "A", PANEL) == la, f"{pair}: раскладка не воспроизводима")
for pair in Z.PAIRS: # панель чтения обязана СОБИРАТЬСЯ, иначе гейт пуст
tx_all = ZS.texts_of(pair)
n = sum(1 for u in pick(pair) if variants(pair, tx_all[u]))
ok(n == N_UNITS, f"{pair}: панель чтения собирается на {n} единицах из {N_UNITS}"
"клетки захода не куплены, эмиссия дала бы неполные пачки")
for m in fails:
print("ПРОВАЛ:", m)
print(f"селфтест чтения: провалов {len(fails)}")
return 1 if fails else 0
if __name__ == "__main__":
a = sys.argv[1:] or ["--selftest"]
fn = {"--emit": emit, "--score": lambda: sys.exit(score()),
"--score-calib": lambda: sys.exit(score_calib()),
"--selftest": lambda: sys.exit(selftest())}.get(a[0])
if not fn:
raise SystemExit(f"⛔ неизвестный режим {a[0]!r}")
fn()