626 lines
46 KiB
Python
626 lines
46 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
|
||
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()
|