diff --git a/eval/dovodka/rol.py b/eval/dovodka/rol.py index 18c2b625..582f6734 100644 --- a/eval/dovodka/rol.py +++ b/eval/dovodka/rol.py @@ -489,10 +489,39 @@ def _e6_wire(uid: str) -> tuple[list[dict], str, dict]: return msgs, EN.EDITOR2, ROSTER.call_kwargs(EN.EDITOR2, msgs) +def _rq_wire(uid: str) -> tuple[list[dict], str, dict]: + """WIRE-АНАЛОГ `RQ` — ЗАПРОС В ТОЙ ФОРМЕ, В КОТОРОЙ ОН УХОДИЛ ДО ПОЧИНКИ СКЛЕЙКИ. + + ⛔ Клетки `RQ` — это `RE`, у которого вендор МОЛЧА ВЫБРОСИЛ второе системное сообщение: + OpenAI-совместимый слой Gemini принимает ровно одно. Поэтому здесь НЕ зовётся `fit_msgs` + (склейка, заведённая после находки) и НЕ зовётся `rol.call_kwargs`: печатается ровно то, что + отправлял код 21.08. ⚠ Печатается ОТПРАВЛЕННОЕ, а не ПОЛУЧЕННОЕ моделью — второе нам недоступно + по построению, и разница видна только по счётчику: 515 токенов входа против 2951 у `RE`. + """ + src = next(u["source"] for u in chosen("en") if u["uid"] == uid) + msgs = rol_msgs("en", src, "RE") + return msgs, ARMS["RE"][0], ROSTER.call_kwargs(ARMS["RE"][0], msgs, max_out=MAX_OUT) + + FOREIGN = { "E6": {"pair": "en", "prefix": "dv-g-e6-", "model": "glm-5", "role": "editor3", "phase": "ФД-G", "rig": "eval/dovodka/en_axis.py --e6", "wire": _e6_wire, "что": "ВТОРОЙ редактор glm-5 поверх боевого черновика D0 (носитель гипотезы H-4)"}, + # ⚠ ПСЕВДО-АРМ ПОД КАРАНТИННЫМ СУФФИКСОМ. 16 клеток `RE`, которые вендор перевёл БЕЗ ИНСТРУКЦИИ: + # OpenAI-совместимый слой Gemini принимает РОВНО ОДНО системное сообщение и молча выбрасывает + # лишние. Деньги за них списаны, тексты годны, `finish=stop` 16/16 — это оплаченный и + # незапланированный природный эксперимент «сколько весит ВЕСЬ промт». + # ⛔ ИМЯ НЕЙТРАЛЬНОЕ И СУФФИКСА В ПАКЕТЕ БЫТЬ НЕ ДОЛЖНО: читатель, увидевший «БЕЗ-ПРОМТА», + # мерил бы подсказку. `arm_field` = "RE", потому что в самой клетке написано «RE» — она и есть + # RE, просто без инструкции; подменять поле в файле нельзя, это правка сырья. + # ⛔ ГЕЙТ ПРАВИЛЬНОСТИ ФАЙЛА — ЧИСЛОМ, А НЕ ИМЕНЕМ: у карантинных `prompt_tokens` 468–548 + # (медиана 515), у нормальных 2904–2984 (медиана 2951). Диапазон ниже разводит их с запасом, + # и перепутанный файл роняет сборку, а не уезжает в панель. + "RQ": {"pair": "en", "prefix": "dv-n-re-", "suffix": ".БЕЗ-ПРОМТА", + "model": "gemini-3.1-flash-lite", "role": "onepass", "arm_field": "RE", + "phase": "ФД-N", "rig": "eval/dovodka/rol.py --buy en RE (клетки 21.08, карантин)", + "prompt_tokens": (300, 900), "wire": _rq_wire, + "что": "тот же gemini БЕЗ ИНСТРУКЦИИ — вендор выбросил системное сообщение"}, } @@ -507,11 +536,18 @@ def foreign_defect(d: dict, arm: str, uid: str) -> str: """ spec = FOREIGN[arm] got_model = d.get("model_returned") or d.get("model") - for what, got, want in (("arm", d.get("arm"), arm), ("uid", d.get("uid"), uid), + for what, got, want in (("arm", d.get("arm"), spec.get("arm_field", arm)), + ("uid", d.get("uid"), uid), ("role", d.get("role"), spec["role"]), ("модель", got_model, spec["model"])): if got != want: return f"поле {what}={got!r}, ожидалось {want!r}" + lo_hi = spec.get("prompt_tokens") + if lo_hi: + pt = d.get("prompt_tokens") + if pt is None or not lo_hi[0] <= pt <= lo_hi[1]: + return (f"prompt_tokens={pt}, а у арма {arm} обязаны быть в {lo_hi} — " + "похоже, подставлен файл другого арма") if d.get("finish") != "stop": return f"finish={d.get('finish')!r} — клетка оборвана либо мертва" if not (d.get("content") or "").strip(): @@ -526,7 +562,7 @@ def foreign_text(p: str, arm: str, uid: str) -> str: if spec["pair"] != p: raise SystemExit(f"⛔ арм {arm} существует только на паре «{spec['pair']}», запрошена «{p}»" f" — его клетки купил {spec['rig']} из кассы {spec['phase']}") - f = OUT / f"{spec['prefix']}{uid}.json" + f = OUT / f"{spec['prefix']}{uid}{spec.get('suffix', '')}.json" if not f.exists(): raise SystemExit(f"⛔ клетки {arm} для главы {uid} нет на диске ({f.name}). Этот риг её " f"купить не может — она из кассы {spec['phase']}, риг {spec['rig']}") @@ -938,20 +974,30 @@ def cmd_selftest() -> int: pp = spec["pair"] PAIRS[pp]["wire"]() us = chosen(pp) - have = [u["uid"] for u in us if (OUT / f"{spec['prefix']}{u['uid']}.json").exists()] + have = [u["uid"] for u in us + if (OUT / f"{spec['prefix']}{u['uid']}{spec.get('suffix', '')}.json").exists()] ck(f"{fa}: клетка есть на КАЖДОЙ главе пары {pp}", len(have) == len(us), f"{len(have)} из {len(us)}") if not have: continue fu = have[0] - real = json.loads((OUT / f"{spec['prefix']}{fu}.json").read_text(encoding="utf-8")) + real = json.loads((OUT / f"{spec['prefix']}{fu}{spec.get('suffix', '')}.json") + .read_text(encoding="utf-8")) ck(f"{fa}: настоящая клетка гейт ПРОХОДИТ", not foreign_defect(real, fa, fu), foreign_defect(real, fa, fu)) - for name, patch in (("чужой арм", {"arm": "RG"}), ("чужая глава", {"uid": "0" * 10}), - ("чужая роль", {"role": "onepass"}), + for name, patch in (("чужой арм", {"arm": "ZZZ"}), ("чужая глава", {"uid": "0" * 10}), + ("чужая роль", {"role": "нетакая"}), ("чужая модель", {"model_returned": "grok-4.3"}), ("обрыв finish=length", {"finish": "length"}), - ("пустой content", {"content": " "})): + ("пустой content", {"content": " "}), + # ⚠ Токенный гейт есть НЕ У ВСЕХ армов, и это правильно: `E6` разводится + # уникальным префиксом файла и ролью `editor3`, а `RQ` лежит под тем же + # префиксом, что нормальный `RE`, — там числовой разводящий обязателен. + # Проверка идёт только там, где гейт объявлен: иначе она мерила бы не + # гейт, а моё желание видеть зелёное. + ("файл другого арма по prompt_tokens", {"prompt_tokens": 99999})): + if "prompt_tokens" in patch and "prompt_tokens" not in spec: + continue ck(f"{fa}: гейт РОНЯЕТ больной вход «{name}»", bool(foreign_defect(dict(real, **patch), fa, fu))) other = next((x for x in PAIRS if x != pp), pp) diff --git a/eval/dovodka/vtoroe.py b/eval/dovodka/vtoroe.py new file mode 100644 index 00000000..ab65f351 --- /dev/null +++ b/eval/dovodka/vtoroe.py @@ -0,0 +1,219 @@ +#!/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 — порог несёт текст, и «неразличимо» кое-что значит. + +⛔ ЧЕГО ЭТОТ ПРИБОР НЕ МЕРИТ, И ЭТО ГЛАВНАЯ ОГОВОРКА: оба круга — ОДНО читательское семейство +(`fable-5`). Согласие двух его чтений говорит про ШУМ и молчит про ВКУС. Ось ВЕРНОСТИ у владельца +разошлась с прибором радикально (Д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 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=")), [])) + sys.exit(report(_tag, "en", _anch, _drop))