textmachine/eval/dovodka/vtoroe.py

278 lines
19 KiB
Python
Raw 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.
⚠ ЗАЧЕМ. У всех наших «неразличимо» до сих пор не было ПАСПОРТА РАЗРЕШЕНИЯ: порог считался по
разбросу парных разностей, но что в этом разбросе — расхождение ТЕКСТОВ или расхождение ЧИТАТЕЛЕЙ —
не мерил никто. Два чтения одних и тех же пакетов разделяют это ровно и без моделей: тексты у обоих
кругов побайтно одни, значит всё, чем круги отличаются, есть читатель.
РАЗЛОЖЕНИЕ, объявленное пре-регом Д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))