textmachine/eval/dovodka/chtenie.py

626 lines
46 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
"""ПРИБОР ТКАНИ — слепое ранжирование читаемости. $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
chtenie.py --emit-owner | --score-owner — пакет ВЛАДЕЛЬЦУ (4 варианта, «жирная глава»)
"""
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 единиц из ОТСУЖЕННОГО набора — чтобы порядки читателя и счётчика сравнивались
на одних и тех же текстах. Отбор детерминирован своей солью.
⚠ ОТБОР ИДЁТ ТОЛЬКО ПО ЕДИНИЦАМ С ПОЛНОЙ ПАНЕЛЬЮ. На китайской оси четыре обогащённых
черновика забракованы эхо-гейтом, значит арма «обогащённый черновик + перепис» на них нет
вовсе. Прежняя редакция брала восемь единиц из всех отсуженных и одна приходила неполной —
а ранги, снятые на панелях разной ширины, между собой несравнимы. Это сужение ВЫБОРКИ ЧТЕНИЯ,
а не панели: сама панель не режется, просто читаются те главы, где она есть целиком.
"""
tx_all = ZS.texts_of(pair)
uids = sorted(u for u in tx_all if all(tx_all[u].get(a, "").strip() for a in PANEL))
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` это учитывают. Молча выпавший ответ читателя стоил бы целой сессии.
# ⚠ ФОРМА ОТВЕТА ЧЕЛОВЕКА ШИРЕ, ЧЕМ Я ПРЕДПИСАЛ, И ЭТО ДЕФЕКТ РАЗБОРЩИКА, А НЕ ОТВЕТА.
# Владелец 17.08 ответил «Т3 > Т1 > Т2 > Т4» под заголовком «Порядок читаемости» и
# «**ПОРЯДОК ЧИТАЕМОСТИ:** `Т1 > Т4 = Т3 > Т2`» — обе формы законны и обе прежним регексом
# не читались: он требовал НОМЕРА отрывка сразу после слова. Инструмент, который не берёт
# честный ответ, обесценивает работу читателя, а не читателя.
# Теперь номер необязателен (нет — нумеруем по порядку появления), markdown-разметка и
# обратные кавычки снимаются, латинская T принимается наравне с кириллической.
_ORD_NUM = re.compile(r"ПОРЯДОК[^\n:]*?(\d+)\s*:", re.IGNORECASE)
_ORD = re.compile(r"((?:[ТT]\d+\s*[>=]\s*)+[ТT]\d+)")
def orders(txt: str) -> dict[str, str]:
"""{номер отрывка: строка порядка}. Номер берётся из подписи «ПОРЯДОК … N:», если она есть;
если её нет — порядки нумеруются по появлению в тексте.
⚠ Заведено 17.08 после того, как разборщик не взял ЧЕСТНЫЙ ответ владельца: он написал
порядок под заголовком «## 1. Порядок читаемости», без обязательного номера сразу за словом.
Требовать от человека машинной формы и молча терять его работу — дефект инструмента.
⚠ Строки порядка ищутся ПО ВСЕМУ тексту, но берутся только те, что стоят ПОСЛЕ слова
«ПОРЯДОК» либо первыми в файле: иначе в разбор попали бы упоминания меток из комментариев
вроде «Т3 и Т1 стоят близко».
⚠ ВТОРАЯ ПОЧИНКА ТОГО ЖЕ ДНЯ: порядок ВЕРНОСТИ отбрасывается. Владелец дал по английской
паре две строки — «ПОРЯДОК ЧИТАЕМОСТИ» и «ПОРЯДОК ВЕРНОСТИ», — а разборщик принял вторую за
порядок второго отрывка и молча отсудил ею чужую главу. Прибор мерит ЧИТАЕМОСТЬ; верность
считается детерминированно и в этот разбор не входит.
"""
out: dict[str, str] = {}
for i, m in enumerate(_ORD.finditer(txt), 1):
head = txt[max(0, m.start() - 200):m.start()]
if "ВЕРНОСТ" in head.upper():
continue
nm = _ORD_NUM.findall(head)
key = nm[-1] if nm else str(len(out) + 1)
out.setdefault(key, m.group(1))
return out
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 = orders(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
# ── ПАКЕТ ДЛЯ ВЛАДЕЛЬЦА: он читает сам, человеческим глазом ─────────────────────────────────────
# ⚠ ЗАЧЕМ ОТДЕЛЬНЫЙ РЕЖИМ, А НЕ ТОТ ЖЕ ПАКЕТ. Агентские пачки собраны под МАШИННОГО читателя:
# восемь глав на ось, семь вариантов, двадцать заданий. Владелец дважды не стал такое читать и
# сказал прямо: «чтоб не слишком много было, давай какую-нибудь жирную главу на язык». Значит
# ограничение здесь — ЕГО ВРЕМЯ, и панель режется под него, а не под статистику.
#
# ЧТО ОСТАВЛЕНО И ПОЧЕМУ РОВНО ЭТО (четыре варианта вместо семи):
# Z0 боевая связка — то, что стоит в продакшене; без неё сравнивать не с чем;
# ZF ВТОРАЯ генерация той же связки — контроль шума. Если владелец разведёт их далеко, его
# порядок есть шум, и «победа» любого арма внутри этого расстояния ничего не значит.
# Это единственный способ узнать, различает ли он вообще что-то, — и он же самый дешёвый;
# ZP однопроходка — один вызов вместо двух, и по банку с канон-фиксером она впереди связки;
# Z1 критик → полный перепис — ставка владельца в сильнейшей форме.
# Голый черновик НЕ включён намеренно: оба прибора и оба машинных читателя ставят его последним
# с огромным отрывом, спора нет, а глава черновика — это лишние 7 тысяч знаков его чтения.
# Обогащённый черновик с переписом тоже снят: от боевой связки он неотличим ни одним прибором.
#
# ⚠ ДВА ВОПРОСА, КАК ПРОСИЛ ВЛАДЕЛЕЦ («лучше по художке и при этом лучше держит канон смысл»), но
# ЧЕСТНО РАЗВЕДЁННЫЕ. Читаемость может назвать только он. Верность исходнику на КИТАЙСКОМ он
# назвать не может — для этого надо читать китайский, — поэтому там его просят отметить лишь то,
# что видно без исходника: провалы связности, непонятные места, оборванные мысли. На английском
# исходник приложен, и сверка возможна, но объявлена необязательной. Канон терминов при этом уже
# посчитан детерминированно и в пакет НЕ кладётся до его ответа — иначе это подсказка.
OWNER_PANEL = ("Z0", "ZF", "ZP", "Z1")
OWNER_DIR = Path.home() / "books" / "chtenie-vladeltsa-z17"
OWNER_KEY = KEYD / "chtenie-vladeltsa-z17-KEY.json"
OWNER_SALT = "vladelets-z17-2026-08-17"
OWNER_N = {"zh": 1, "en": 2} # «жирная глава на язык»; английские главы втрое короче
OWNER_HEAD = """# Что ты читаешь и зачем — коротко
Ниже {n} перевода одного и того же отрывка. Сделаны они разными способами: где-то один вызов
модели, где-то два, где-то с промежуточным критиком. **Какой чем сделан — не сказано намеренно.**
Метки перемешаны, расшифровка лежит в отдельном файле и до твоего ответа не открывается.
**Зачем это нужно.** Наш измеритель считает локальные ошибки — искажения смысла, кальки, сбитую
вёрстку. Он дёшев и воспроизводим, но у него встроенная слепота: текст может быть без единой
ошибки и при этом мёртвый. На последнем прогоне это подтвердилось числом — порядок по счёту ошибок
и порядок по читаемости совпали лишь на треть, а вариант с наименьшим числом ошибок читался
почти хуже всех. Твой ответ решает, верить ли дальше счётчику или чтению.
**Одна из четырёх версий — контроль.** Две из них сделаны ОДНИМ И ТЕМ ЖЕ способом, просто модель
вызвана дважды. Какие именно — не скажу. Если ты поставишь их рядом, значит ты различаешь способы;
если разведёшь далеко, значит разница между способами меньше собственного разброса модели, и тогда
никакая «победа» в этом замере ничего не стоит. Специально их искать не надо — просто читай.
Метки сквозные: `Т1` в первом отрывке и `Т1` во втором — ОДИН И ТОТ ЖЕ способ.
Поэтому порядок можно называть и по каждому отрывку, и один общий на всю пару.
⚠ **Что может сбить.** Вёрстка выдаёт метод: часть вариантов сливает построчный исходник в русские
абзацы, часть сохраняет разбивку. Это видно ДО чтения — не давай форме решать за тебя.
---
## Что записать — в файл `{answer}`
1. **ПОРЯДОК ЧИТАЕМОСТИ** — строкой: `Т3 > Т1 > Т2 > Т4`, от лучшего к худшему.
Неразличимые соединяй через `=`. Это главный вопрос: какой текст ты взял бы в книгу.
2. **ПОРЯДОК ВЕРНОСТИ** — {fidelity}
3. **По фразе на вариант**: почему. Годится и «серо, но гладко», и «живой ритм, но во втором
абзаце теряется, кто кому говорит».
Если какой-то вариант покажется откровенно машинным — скажи прямо, это самое ценное.
---
"""
# ⚠ Верность исходнику владелец может назвать только там, где читает исходный язык. На китайском
# он его не читает, и просить «сверь смысл» значило бы просить невыполнимого — а невыполнимая
# просьба в задании обесценивает и выполнимую часть. Поэтому на zh просят то, что видно БЕЗ
# исходника: провалы связности. Это не подмена вопроса, а его честная граница.
OWNER_FID = {
"zh": ("исходник китайский, и сверять его тебе нечем — поэтому этот пункт ПРОПУСТИ.\n"
" Вместо него отметь то, что видно и без исходника: где текст теряет связность,\n"
" где непонятно, кто что делает, где мысль обрывается. Такие места почти всегда\n"
" и оказываются враньём про исходник."),
"en": ("исходник приложен, английский. Если станешь сверять — назови порядок по верности\n"
" отдельной строкой. Не станешь — пропусти, это необязательно."),
}
def emit_owner(only: list[str] | None = None) -> None:
"""Пакет владельцу. `only` — пересобрать ТОЛЬКО названные пары.
⚠⚠ ПЕРЕ-ЭМИССИЯ НЕ ИМЕЕТ ПРАВА ТРОНУТЬ ПАРУ, КОТОРУЮ ВЛАДЕЛЕЦ УЖЕ ПРОЧЁЛ. Раскладка меток
есть чистая функция соли и набора армов; пересборка ЦЕЛОГО пакета переписала бы ключ и
китайской пары тоже — а её ответ уже дан и расшифрован. Тогда та же расшифровка при следующем
запуске дала бы ДРУГИЕ армы, и работа владельца превратилась бы в мусор молча. Ровно так фаза
Д однажды потеряла вердикт `E0/D0` (+1.56 → +0.38). Поэтому ключ здесь ДОГРУЖАЕТСЯ, а не
создаётся заново, и пары вне `only` не пересчитываются.
⚠ Прежний ответ по пересобираемой паре не удаляется, а уводится в архив: это улика — работа
владельца, испорченная дефектом МОЕЙ сборки, а не его чтением.
"""
key: dict = {"_что": "слепое чтение ВЛАДЕЛЬЦЕМ, панель захода Д17", "_соль": OWNER_SALT,
"_панель": list(OWNER_PANEL),
"онтроль": "Z0 и ZF — две генерации ОДНОЙ боевой связки; развёл далеко = шум"}
if OWNER_KEY.exists():
key = {**json.loads(OWNER_KEY.read_text(encoding="utf-8")), **key}
OWNER_DIR.mkdir(parents=True, exist_ok=True)
for pair, langname, srcname in (("zh", "КИТАЙСКАЯ", "китайский"),
("en", "АНГЛИЙСКАЯ", "английский")):
if only and pair not in only:
print(f" {pair}: не в списке пересборки — ключ и задание НЕ трогаются")
continue
prev = OWNER_DIR / f"ОТВЕТ-{pair}.md"
if prev.exists() and _ORD.search(prev.read_text(encoding="utf-8")):
arch = OWNER_DIR / f"ОТВЕТ-{pair}.ИСПОРЧЕННЫЙ-ПАКЕТ.md"
arch.write_text(prev.read_text(encoding="utf-8"), encoding="utf-8")
prev.unlink()
print(f" {pair}: прежний ответ уведён в {arch.name} (улика, не удалён)")
tx_all = ZS.texts_of(pair)
# «жирная глава»: самая длинная из тех, где панель полна — длинная честнее короткой
cand = [u for u in tx_all if all(tx_all[u].get(a, "").strip() for a in OWNER_PANEL)]
cand.sort(key=lambda u: -sum(len(tx_all[u][a]) for a in OWNER_PANEL))
take = cand[:OWNER_N[pair]]
ans = OWNER_DIR / f"ОТВЕТ-{pair}.md"
body = [f"# {langname} ПАРА — слепое чтение\n",
OWNER_HEAD.format(n=len(OWNER_PANEL), answer=ans, fidelity=OWNER_FID[pair])]
for j, uid in enumerate(take, 1):
# ⚠ РАСКЛАДКА ОДНА НА ВСЮ ПАРУ, А НЕ СВОЯ У КАЖДОГО ОТРЫВКА (починка 17.08).
# Первая редакция солила раскладку номером главы, и метка `Т1` означала РАЗНЫЕ
# армы в первом и втором английском отрывке. Владелец — как поступил бы любой
# читатель — дал ОДИН сводный порядок на пару и говорил о «Т1» как об одном
# варианте; сопоставить это с армами оказалось нечем, и английская половина
# его работы пропала не по его вине. Машинным читателям своя раскладка на
# единицу нужна (они судят поединично), человеку — вредна.
lay = layout(pair, "OWNER-ALL-v2", "OWNER", OWNER_PANEL)
body += [f"\n# ОТРЫВОК {j}\n", f"## Исходник ({srcname})\n",
tx_all[uid]["_src"], ""]
for lab, arm in lay.items():
body += ["\n---\n", f"## Отрывок {j}, вариант {lab}\n", tx_all[uid][arm], ""]
key.setdefault(pair, {})[str(j)] = {
"uid": uid, "метки": lay,
"знаков": {a: len(tx_all[uid][a]) for a in OWNER_PANEL}}
(OWNER_DIR / f"ЧТЕНИЕ-{pair}.md").write_text("\n".join(body), encoding="utf-8")
if not ans.exists():
ans.write_text(
f"# Ответ — {langname.lower()} пара\n\n"
+ "".join(f"ПОРЯДОК ЧИТАЕМОСТИ {j}: \n" for j in range(1, len(take) + 1))
+ "\nЗаметки по вариантам:\n", encoding="utf-8")
n = sum(len(tx_all[u][a]) for u in take for a in OWNER_PANEL)
print(f" {pair}: глав {len(take)} · вариантов {len(OWNER_PANEL)} · {n // 1000} тыс. знаков")
OWNER_KEY.write_text(json.dumps(key, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"\nпакет → {OWNER_DIR}")
print(f"ключ → {OWNER_KEY} (НЕ открывать до ответа)")
def score_owner() -> int:
if not OWNER_KEY.exists():
print("ключа нет — сначала --emit-owner")
return 1
key = json.loads(OWNER_KEY.read_text(encoding="utf-8"))
sc, _bad = ZS.read()
for pair in ("zh", "en"):
f = OWNER_DIR / f"ОТВЕТ-{pair}.md"
if not f.exists() or pair not in key:
continue
txt = f.read_text(encoding="utf-8")
got = orders(txt)
if not got:
print(f"{pair}: порядка нет — ждём ответа владельца")
continue
print(f"\n=== {pair} ===")
# ⚠ СТОРОЖ НЕОДНОЗНАЧНОЙ РАСШИФРОВКИ (заведён 17.08 по факту). Если в ключе у отрывков
# РАЗНЫЕ раскладки меток, а читатель дал ОДИН сводный порядок, то механическое применение
# этого порядка к каждой раскладке даёт столько же РАЗНЫХ расшифровок, сколько отрывков, и
# все они одинаково «правдоподобны». Так и вышло с английской парой: один и тот же ответ
# владельца дал «однопроходка первая» на первом отрывке и «однопроходка ПОСЛЕДНЯЯ» на
# втором. Печатать это как результат — значит выдать артефакт разметки за замер.
maps = [tuple(sorted(m["метки"].items())) for m in key[pair].values()]
if len(set(maps)) > 1 and len(got) < len(key[pair]):
print(" ⛔ РАСШИФРОВКА НЕОДНОЗНАЧНА И НЕ ВЫВОДИТСЯ. У отрывков РАЗНЫЕ раскладки")
print(" меток, а порядок дан ОДИН сводный: одна и та же метка означает в них")
print(" разные армы. Это дефект СБОРКИ ПАКЕТА, а не ответа читателя.")
print(" Что уцелело — устойчивое к раскладке: какие метки читатель поставил")
print(" РЯДОМ или связал знаком «=». Ниже только это.")
for j, meta in key[pair].items():
if j not in got:
continue
r = _ranks(got[j])
pairs = [(a, b) for a in r for b in r if a < b and abs(r[a] - r[b]) <= 1]
for a, b in pairs:
arms = {meta["метки"].get(a), meta["метки"].get(b)}
print(f" {a} и {b} рядом → в этом отрывке это {' и '.join(sorted(x for x in arms if x))}")
continue
for j, meta in key[pair].items():
if j not in got:
continue
r = _ranks(got[j])
rows = [(meta["метки"][lab], r[lab]) for lab in meta["метки"] if lab in r]
print(f" отрывок {j} (глава {meta['uid']}):")
for arm, rank in sorted(rows, key=lambda x: x[1]):
errs = [ZS._carry(ax) for (pp, u, aa, _h), ax in sc.items()
if pp == pair and aa == arm and u == meta["uid"]]
e = f"{st.mean(errs):.1f}" if errs else ""
print(f" {rank:.1f} {arm:4s} ошибок по судье {e}")
d = {a: rk for a, rk in rows}
if "Z0" in d and "ZF" in d:
gap = abs(d["Z0"] - d["ZF"])
print(f" КОНТРОЛЬ ШУМА: две генерации одной связки разведены на {gap:.1f} "
f"позиции → {'порядок несёт сигнал' if gap <= 1 else 'ПОРЯДОК = ШУМ'}")
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 на инверсии")
ok(orders("ПОРЯДОК 2: Т3 > Т1 > Т5") == {"2": "Т3 > Т1 > Т5"}, "нумерованный порядок")
ok(list(orders("## Порядок читаемости\n\nТ3 > Т1 > Т2 > Т4").values()) == ["Т3 > Т1 > Т2 > Т4"],
"ЧЕЛОВЕЧЕСКАЯ форма ответа без номера не читается — на ней уже терялась работа владельца")
ok(list(orders("**ПОРЯДОК ЧИТАЕМОСТИ:** `Т1 > Т4 = Т3 > Т2`").values()) == ["Т1 > Т4 = Т3 > Т2"],
"порядок в markdown-разметке не читается")
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()),
"--emit-owner": lambda: emit_owner([x for x in a[1:] if x in ("zh", "en")] or None),
"--score-owner": lambda: sys.exit(score_owner()),
"--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()