374 lines
23 KiB
Python
374 lines
23 KiB
Python
#!/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()
|