#!/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 единиц из ОТСУЖЕННОГО набора — чтобы порядки читателя и счётчика сравнивались на одних и тех же текстах. Отбор детерминирован своей солью. ⚠ ОТБОР ИДЁТ ТОЛЬКО ПО ЕДИНИЦАМ С ПОЛНОЙ ПАНЕЛЬЮ. На китайской оси четыре обогащённых черновика забракованы эхо-гейтом, значит арма «обогащённый черновик + перепис» на них нет вовсе. Прежняя редакция брала восемь единиц из всех отсуженных и одна приходила неполной — а ранги, снятые на панелях разной ширины, между собой несравнимы. Это сужение ВЫБОРКИ ЧТЕНИЯ, а не панели: сама панель не режется, просто читаются те главы, где она есть целиком. """ 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` это учитывают. Молча выпавший ответ читателя стоил бы целой сессии. _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()