272 lines
18 KiB
Python
272 lines
18 KiB
Python
#!/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 = ()) -> int:
|
||
r1, r2 = ranks_of(tag, p, "r1"), ranks_of(tag, p, "r2")
|
||
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 = ()) -> int:
|
||
"""СРАВНЕНИЕ ДВУХ СУДЕЙСКИХ СЕМЕЙСТВ — то, чего норме П-1 не хватало с 20.08.
|
||
|
||
⚠ Заведено 23.08 по слову владельца: «раз уж ты пробежал какое-то судейство другим агентом,
|
||
заодно можно и сравнить будет их». До этого дня всё судейство фазы вело ОДНО семейство
|
||
(`fable-5`), и общий ВКУС семейства нельзя было отделить от качества перевода в принципе —
|
||
ограничение записано в `READERS-arch2.json` как ⚠_ограничение. Круг 2 дал второе семейство
|
||
(`opus-5`) — пусть и по моей ошибке, а не по замыслу.
|
||
|
||
⛔ ЧТО МЕРИТСЯ И ЧТО НЕТ. Места КОМПОЗИЦИОННЫ: если один арм поднялся, другой обязан опуститься,
|
||
и сумма мест по главе константна. Значит «систематический сдвиг арма» есть утверждение
|
||
ОТНОСИТЕЛЬНОЕ — «семейства по-разному упорядочивают», — а не «одно строже другого». Абсолютной
|
||
строгости этот прибор не видит и видеть не может.
|
||
"""
|
||
r1, r2 = ranks_of(tag, p, "r1"), ranks_of(tag, p, "r2")
|
||
uids = [u for u in sorted(set(r1) & set(r2)) if u not in drop]
|
||
arms = sorted(r1[uids[0]])
|
||
print(f"\n=== ВКУС ДВУХ СЕМЕЙСТВ, {tag}: fable-5 (круг 1) против opus-5 (круг 2) ===")
|
||
print(f" глав {len(uids)} · армов {len(arms)}\n")
|
||
print(f" {'арм':6s}{'fable':>8s}{'opus':>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 = "⛔ opus судит ИНАЧЕ" 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(" ⚠ сдвиг >0 значит «opus ставит арм НИЖЕ, чем fable»; сумма сдвигов по построению ≈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=")), []))
|
||
if "--vkus" in a:
|
||
sys.exit(vkus(_tag, "en", _drop))
|
||
sys.exit(report(_tag, "en", _anch, _drop))
|