#!/usr/bin/env python3 """ВТОРОЕ ЧТЕНИЕ: СКОЛЬКО В ПОРОГЕ ОТ ПРИБОРА, А СКОЛЬКО ОТ ТЕКСТА. $0. ⚠ ЗАЧЕМ. У всех наших «неразличимо» до сих пор не было ПАСПОРТА РАЗРЕШЕНИЯ: порог считался по разбросу парных разностей, но что в этом разбросе — расхождение ТЕКСТОВ или расхождение ЧИТАТЕЛЕЙ — не мерил никто. Два чтения одних и тех же пакетов разделяют это ровно и без моделей: тексты у обоих кругов побайтно одни, значит всё, чем круги отличаются, есть читатель. РАЗЛОЖЕНИЕ, объявленное пре-регом Д33.2 ДО чисел. Для пары армов (a,b) и главы u парная разность мест `D_r(u) = место_r(a,u) − место_r(b,u)`, где r — круг. Модель `D_r = T + E_r`: общий текстовый эффект T (у кругов один, тексты те же) плюс независимый читательский шум E_r. Тогда Var(D1) = σ²_T + σ²_E Var(D1 − D2) = 2·σ²_E ⇒ ДОЛЯ ПРИБОРА = σ²_E / Var(D1) = Var(D1 − D2) / (2·Var(D1)) Доля близка к 1 — порог фазы есть почти чистый шум чтения, и «неразличимо» означает «прибор слеп». Доля близка к 0 — порог несёт текст, и «неразличимо» кое-что значит. ⛔⛔ ЧТО ИМЕННО РАЗДЕЛЯЕТ ЭТОТ ПРИБОР — ИСПРАВЛЕНО 23.08 ПО ВОПРОСУ ВЛАДЕЛЬЦА. Первая редакция этой шапки утверждала «оба круга — ОДНО читательское семейство (`fable-5`)». **ЭТО БЫЛО НЕВЕРНО, и я написал это НЕ ПРОВЕРИВ.** Круг 1 всех панелей записан как `claude-fable-5` (`READERS-*.json`, решение владельца 20.08 «отдадим всё судейство фаблу»), а мои читатели ушли на **`claude-opus-5`**: суб-агент наследует модель сессии, а я не передал переопределения. Доказано транскриптами суб-агентов (`agent-*.jsonl`, поле `model`), а не записью о намерении. ⇒ `E_r` в разложении ниже есть НЕ читательский шум ОДНОГО семейства, а **цена ПОЛНОЙ СМЕНЫ семейства** `fable-5 → opus-5`. Это не мусор и по-своему сильнее: два РАЗНЫХ семейства, сошедшихся на медиане Спирмена +0.84, — более требовательная проверка, чем повтор одним и тем же. Но объявленной метрики «сколько в пороге ОТ ПРИБОРА» это НЕ даёт: внутрисемейный разброс остаётся НЕ ЗАМЕРЕННЫМ, и паспорт разрешения по-прежнему без своего числа. ⚠ И прежняя оговорка в силе, только сильнее: согласие двух МАШИННЫХ семейств всё равно молчит про ВКУС. Ось ВЕРНОСТИ у владельца разошлась с прибором радикально (Д30) — второе чтение этого не трогает вовсе. ⚠ И вторая: обёртку запуска ПЕРВОГО круга не хранит ни один артефакт (Д33.4). Значит в разницу кругов входит не только читатель, но и неизвестная разница обёрток. Направление вклада неизвестно. Запуск: vtoroe.py --tag=vendor2 [--anchor=RN] [--drop=uid8,uid8] vtoroe.py --selftest """ from __future__ import annotations import importlib.util import json import math 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 R = _load("rol_vt", ZONE / "rol.py") # разбор порядка и раскладки — у него, второго не пишем CH = R.CH ZS = _load("zsud_vt", ZONE / "zsud.py") # знаковый тест — один на зону OUT = R.OUT READ = R.READ_DIR def ranks_of(tag: str, p: str, ansdir: str) -> dict: """{uid8: {арм: место}} для одного круга. Глава без разобранного порядка выпадает ВСЛУХ.""" out, bad = {}, [] for kf in sorted((OUT / "blind-keys-rol").glob(f"rol-KEY-{tag}-*.json")): uid8 = kf.stem.split(f"{tag}-")[1] key = json.loads(kf.read_text(encoding="utf-8")) if p not in key: continue lay = key[p]["1"]["метки"] af = (READ / ansdir) / f"ОТВЕТ-{p}-{tag}-{uid8}.md" if not af.exists(): bad.append((uid8, f"ответа нет в {ansdir}/")) continue txt = af.read_text(encoding="utf-8", errors="replace") places = CH._ranks(R._order_of(txt, set(lay))) # noqa: SLF001 if sorted(places) != sorted(lay): bad.append((uid8, f"порядок покрывает {len(places)} из {len(lay)} меток")) continue out[uid8] = {lay[lab]: pos for lab, pos in places.items()} for uid8, why in bad: print(f" ⚠ {ansdir}/{uid8}: {why}") return out def spearman(a: list, b: list) -> float: """Пирсон по местам. Места уже средние для связок (`chtenie._ranks`), поэтому это и есть Спирмен с поправкой на связки — отдельной формулы не нужно.""" n = len(a) if n < 3: return float("nan") ma, mb = st.mean(a), st.mean(b) num = sum((x - ma) * (y - mb) for x, y in zip(a, b)) den = math.sqrt(sum((x - ma) ** 2 for x in a) * sum((y - mb) ** 2 for y in b)) return num / den if den else float("nan") def contrasts(rk: dict, uids: list, arms: list, anchor: str) -> dict: """{арм: (разрыв, порог, знаковый p)} против якоря — форма порога та же, что в своде.""" out = {} for a in arms: if a == anchor: continue d = [rk[u][a] - rk[u][anchor] for u in uids] out[a] = (st.mean(d), 2.8 * st.pstdev(d) / len(d) ** 0.5, ZS._sign_p(d)) # noqa: SLF001 return out def verdict(gap: float, thr: float, p: float) -> str: """Конъюнкция пре-рега Д17.4: порог И знаковый p<0.05. Иначе — «в пределах».""" return "РАЗЛИЧИМ" if abs(gap) > thr and p < 0.05 else "в пределах" def report(tag: str = "vendor2", p: str = "en", anchor: str = "RN", drop: tuple = (), da: str = "r1", db: str = "r2") -> int: r1, r2 = ranks_of(tag, p, da), ranks_of(tag, p, db) uids = [u for u in sorted(set(r1) & set(r2)) if u not in drop] if not uids: raise SystemExit("⛔ нет глав, прочитанных ОБОИМИ кругами") arms = sorted(r1[uids[0]]) print(f"\n=== ВТОРОЕ ЧТЕНИЕ {tag}, пара {p} ===") print(f" глав в обоих кругах: {len(uids)} (в круге 1 всего {len(r1)}, в круге 2 {len(r2)})") print(f" армов {len(arms)} · якорь {anchor}" + (f" · ВЫБРОШЕНЫ: {', '.join(drop)}" if drop else "")) # ── (i) СОГЛАСИЕ ПОРЯДКОВ ──────────────────────────────────────────────────────────────── print(f"\n(i) СОГЛАСИЕ ПОРЯДКОВ по главе (Спирмен, {len(arms)} армов):") rho = [] for u in uids: r = spearman([r1[u][a] for a in arms], [r2[u][a] for a in arms]) rho.append(r) print(f" {u}: ρ = {r:+.2f}") print(f" медиана ρ = {st.median(rho):+.2f} · среднее {st.mean(rho):+.2f} · " f"минимум {min(rho):+.2f} · максимум {max(rho):+.2f}") print(f" ⚠ ρ=0 значит «второе чтение не воспроизводит первое НИКАК»; ρ=1 — точная копия") # ── (iii) СКОЛЬКО В ПОРОГЕ ОТ ПРИБОРА ──────────────────────────────────────────────────── print("\n(iii) РАЗЛОЖЕНИЕ ПОРОГА: доля ЧИТАТЕЛЯ в дисперсии парной разности") shares, rows = [], [] for i, a in enumerate(arms): for b in arms[i + 1:]: d1 = [r1[u][a] - r1[u][b] for u in uids] d2 = [r2[u][a] - r2[u][b] for u in uids] v1 = st.pvariance(d1) ve = st.pvariance([x - y for x, y in zip(d1, d2)]) / 2 sh = ve / v1 if v1 else float("nan") rows.append((f"{a}−{b}", v1, ve, sh)) if v1: shares.append(min(sh, 2.0)) rows.sort(key=lambda x: -x[1]) print(f" {'пара':14s}{'Var(D1)':>10s}{'σ²читателя':>12s}{'доля':>8s}") for name, v1, ve, sh in rows[:8]: print(f" {name:14s}{v1:10.2f}{ve:12.2f}{sh:8.2f}") print(f" … всего пар {len(rows)}") print(f" ⭐ МЕДИАННАЯ ДОЛЯ ЧИТАТЕЛЯ = {st.median(shares):.2f} " f"(среднее {st.mean(shares):.2f}, пар с долей ≥1.0: " f"{sum(1 for x in shares if x >= 1.0)} из {len(shares)})") print(" ⚠ доля 1.0 = читательский шум ОДИН объясняет весь наблюдённый разброс, текстового " "вклада прибор не видит; доля >1.0 = круги расходятся СИЛЬНЕЕ, чем разошлись бы\n" " независимые (анти-согласие), и порог тогда не мерит вообще ничего") # ── (ii) ПЕРЕ-СЧЁТ КОНТРАСТОВ И ПЕРЕВЁРНУТЫЕ ВЕРДИКТЫ ──────────────────────────────────── c1, c2 = contrasts(r1, uids, arms, anchor), contrasts(r2, uids, arms, anchor) print(f"\n(ii) КОНТРАСТЫ ПРОТИВ ЯКОРЯ {anchor} — оба круга на ОДНИХ {len(uids)} главах:") print(f" {'арм':5s}{'круг1 разрыв':>13s}{'порог':>7s}{'p':>8s} {'вердикт1':11s}" f"{'круг2 разрыв':>13s}{'порог':>7s}{'p':>8s} {'вердикт2':11s}") flips = [] for a in sorted(c1, key=lambda x: c1[x][0]): g1, t1, p1 = c1[a] g2, t2, p2 = c2[a] v1, v2 = verdict(g1, t1, p1), verdict(g2, t2, p2) if v1 != v2: flips.append((a, v1, v2)) print(f" {a:5s}{g1:+13.2f}{t1:7.2f}{p1:8.4f} {v1:11s}" f"{g2:+13.2f}{t2:7.2f}{p2:8.4f} {v2:11s}{' ⛔ ПЕРЕВЁРНУТ' if v1 != v2 else ''}") print(f"\n ПЕРЕВЁРНУТЫХ ВЕРДИКТОВ: {len(flips)} из {len(c1)}" + ("" if not flips else " — " + ", ".join(f"{a}: {x}→{y}" for a, x, y in flips))) return 0 def vkus(tag: str = "vendor2", p: str = "en", drop: tuple = (), da: str = "r1", db: str = "r2", na: str = "fable-5", nb: str = "opus-5") -> int: """СРАВНЕНИЕ ДВУХ СУДЕЙСКИХ СЕМЕЙСТВ — то, чего норме П-1 не хватало с 20.08. ⚠ Заведено 23.08 по слову владельца: «раз уж ты пробежал какое-то судейство другим агентом, заодно можно и сравнить будет их». До этого дня всё судейство фазы вело ОДНО семейство (`fable-5`), и общий ВКУС семейства нельзя было отделить от качества перевода в принципе — ограничение записано в `READERS-arch2.json` как ⚠_ограничение. Круг 2 дал второе семейство (`opus-5`) — пусть и по моей ошибке, а не по замыслу. ⛔ ЧТО МЕРИТСЯ И ЧТО НЕТ. Места КОМПОЗИЦИОННЫ: если один арм поднялся, другой обязан опуститься, и сумма мест по главе константна. Значит «систематический сдвиг арма» есть утверждение ОТНОСИТЕЛЬНОЕ — «семейства по-разному упорядочивают», — а не «одно строже другого». Абсолютной строгости этот прибор не видит и видеть не может. """ r1, r2 = ranks_of(tag, p, da), ranks_of(tag, p, db) uids = [u for u in sorted(set(r1) & set(r2)) if u not in drop] arms = sorted(r1[uids[0]]) print(f"\n=== ВКУС ДВУХ СЕМЕЙСТВ, {tag}: {na} ({da}/) против {nb} ({db}/) ===") print(f" глав {len(uids)} · армов {len(arms)}\n") print(f" {'арм':6s}{na[:7]:>8s}{nb[:7]:>8s}{'сдвиг':>8s}{'глав ↑':>8s}{'глав ↓':>8s}{'p':>9s} вывод") rows = [] for a in arms: d = [r2[u][a] - r1[u][a] for u in uids] # >0 — opus ставит НИЖЕ (место больше) m = st.mean(d) nz = [x for x in d if x] pv = ZS._sign_p(d) # noqa: SLF001 rows.append((a, st.mean([r1[u][a] for u in uids]), st.mean([r2[u][a] for u in uids]), m, sum(1 for x in nz if x < 0), sum(1 for x in nz if x > 0), pv)) for a, m1, m2, m, up, dn, pv in sorted(rows, key=lambda x: x[3]): mark = f"⛔ {nb} судит ИНАЧЕ" if pv < 0.05 else "" print(f" {a:6s}{m1:8.2f}{m2:8.2f}{m:+8.2f}{up:8d}{dn:8d}{pv:9.4f} {mark}") sig = [r for r in rows if r[6] < 0.05] print(f"\n армов, чьё место семейства ставят РАЗЛИЧИМО по-разному: {len(sig)} из {len(arms)}" + ("" if not sig else " — " + ", ".join(r[0] for r in sig))) print(f" ⚠ сдвиг >0 значит «{nb} ставит арм НИЖЕ, чем {na}»; сумма сдвигов по построению ≈0") return 0 def cmd_selftest() -> int: bad = 0 def ck(n: str, ok: bool, d: str = "") -> None: nonlocal bad bad += not ok print(f"[{'OK ' if ok else 'ПРОВАЛ'}] {n}" + (f" {d}" if d else "")) ck("Спирмен: точная копия даёт +1", abs(spearman([1, 2, 3, 4], [1, 2, 3, 4]) - 1) < 1e-9) ck("Спирмен: обратный порядок даёт −1", abs(spearman([1, 2, 3, 4], [4, 3, 2, 1]) + 1) < 1e-9) ck("Спирмен: несвязанные ~0", abs(spearman([1, 2, 3, 4], [2, 1, 4, 3])) < 0.7) # ⛔ РАЗЛОЖЕНИЕ ПРОВЕРЯЕТСЯ НА ЗАВЕДОМО ИЗВЕСТНОМ ВХОДЕ, а не на живых данных: иначе «доля 0.9» # нечем отличить от арифметической ошибки. Два синтетических предела и один смешанный. same = [3, -1, 2, 0, 4, -2] ve = st.pvariance([x - y for x, y in zip(same, same)]) / 2 ck("предел А: круги СОВПАДАЮТ → доля читателя 0", abs(ve) < 1e-12, f"σ²={ve}") # ⚠ ПЕРВАЯ РЕДАКЦИЯ ЭТОГО ГЕЙТА БЫЛА НЕГОДНОЙ и упала при первом же прогоне: «независимыми» # я взял вектор и его ОТРИЦАНИЕ, то есть анти-корреляцию, и получил долю 2.00 вместо 1.00. # Ошибка полезная: она показала, что доля БОЛЬШЕ единицы — законный исход и означает не # «шум объясняет всё», а «читатели расходятся СИЛЬНЕЕ, чем разошлись бы независимо». a1, a2 = [1, 1, -1, -1], [1, -1, 1, -1] # ортогональны: ковариация ровно 0 sh = (st.pvariance([x - y for x, y in zip(a1, a2)]) / 2) / st.pvariance(a1) ck("предел Б: круги НЕЗАВИСИМЫ → доля читателя = 1", abs(sh - 1) < 1e-9, f"доля={sh:.2f}") a3 = [-1, -1, 1, 1] # ровно противоположный первому sh2 = (st.pvariance([x - y for x, y in zip(a1, a3)]) / 2) / st.pvariance(a1) ck("предел В: круги ПРОТИВОПОЛОЖНЫ → доля 2 (законный исход, не баг)", abs(sh2 - 2) < 1e-9, f"доля={sh2:.2f}") ck("конъюнкция: порог пройден, p велик → в пределах", verdict(3.0, 2.0, 0.30) == "в пределах") ck("конъюнкция: p мал, порог не пройден → в пределах", verdict(1.0, 2.0, 0.01) == "в пределах") ck("конъюнкция: оба условия → РАЗЛИЧИМ", verdict(3.0, 2.0, 0.01) == "РАЗЛИЧИМ") print(f"\n{'ПРИБОР ГОДЕН' if not bad else f'ПРОВАЛОВ: {bad}'}") return bad if __name__ == "__main__": a = sys.argv[1:] or ["--selftest"] if a[0] == "--selftest": sys.exit(1 if cmd_selftest() else 0) _tag = next((x.split("=", 1)[1] for x in a if x.startswith("--tag=")), "vendor2") _anch = next((x.split("=", 1)[1] for x in a if x.startswith("--anchor=")), "RN") _drop = tuple(next((x.split("=", 1)[1].split(",") for x in a if x.startswith("--drop=")), [])) _da = next((x.split("=", 1)[1] for x in a if x.startswith("--a=")), "r1") _db = next((x.split("=", 1)[1] for x in a if x.startswith("--b=")), "r2") _na = next((x.split("=", 1)[1] for x in a if x.startswith("--na=")), "fable-5") _nb = next((x.split("=", 1)[1] for x in a if x.startswith("--nb=")), "opus-5") if "--vkus" in a: sys.exit(vkus(_tag, "en", _drop, _da, _db, _na, _nb)) sys.exit(report(_tag, "en", _anch, _drop, _da, _db))