textmachine/eval/editor_tier/judge.py

251 lines
14 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 сам по себе; расходует агент-запуски (кап 60).
Риг не пере-изобретается: `absjudge` эксп-21 настраивается через `configure`, как это делал
эксп-22. Пак-специфика здесь — только два прохода и их армы.
ПРОХОД `tier` — панель сильного тира: T1..Tn против R0 над ТОЙ ЖЕ якорной базой, 16 единиц
эксп-22. Отвечает: остаётся ли `deepseek-v4-pro` рекомендацией при полной панели.
ПРОХОД `border`— граница `glm-5`: R0 против R2 на 32 единицах (16 эксп-22 + 16 новых). Отвечает:
разводится ли контраст 2.12 от порога при удвоении n.
⚠ Порог различимости фиксируется ОДИН — шумовой пол СВОЕГО прохода. Двусмысленность эксп-22, где
пред-объявленный (2.03) и фактический (2.47) пороги дали для `glm-5` разный ответ, закрыта здесь
заранее, а не после замера.
"""
from __future__ import annotations
import hashlib
import json
import sys
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
ZONE = Path(__file__).resolve().parent
sys.path.insert(0, str(REPO / "eval" / "tenant_panel"))
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
import absjudge as AJ # noqa: E402
def _load(name: str, path: Path):
import importlib.util # noqa: PLC0415
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
PAN = _load("panel_et", ZONE / "panel.py")
MONEY, MAT, P22 = PAN.MONEY, PAN.MAT, PAN.P22
OUT = MONEY.OUT
KEYS = OUT / "blind-keys"
RUNS = OUT / "agent-runs.json"
CAP = 60
PASSES = ("tier", "border")
def arm_map(pass_: str) -> dict[str, str]:
if pass_ == "tier":
return {"R0": PAN.BATTLE, **{f"T{i}": m for i, m in enumerate(PAN.survivors(), start=1)},
"F": PAN.ANCHOR} # голый якорный черновик — база позитивного контроля
return {"R0": PAN.BATTLE, "R2": PAN.BORDER}
def arms(pass_: str) -> tuple[str, ...]:
return tuple(arm_map(pass_))
def family(pass_: str) -> list[tuple[str, str, str]]:
"""Семейство объявляется ДО выпуска и впечатывается в ключ."""
if pass_ == "tier":
fam = [(a, "R0", f"редактор сильного тира {a} против боевого над ТОЙ ЖЕ базой")
for a in arms(pass_) if a not in ("R0", "F")]
# ⚠ ПОЗИТИВНЫЙ КОНТРОЛЬ, обещанный планом и пропущенный первой редакцией: покупает ли
# боевой редактор что-нибудь поверх голого черновика. Без него проход не отличает
# «армы равны» от «судья ничего не различает».
fam.append(("R0", "F", "КОНТРОЛЬ: покупает ли боевой редактор поверх якорного черновика"))
return fam
return [("R2", "R0", "пограничный glm-5 против боевого, n удвоено до 32")]
def units(pass_: str) -> list[dict]:
"""Единицы прохода. У `border` их 32: 16 эксп-22 плюс 16 новых этого пака."""
if pass_ == "tier":
return P22.M.units_zh()
return P22.M.units_zh() + MAT.units_new()
def text_of(pass_: str):
new_uids = {u["uid"] for u in MAT.units_new()}
def hook(arm: str, u: dict) -> str:
if pass_ == "border":
# единица новая → тексты фазы B; единица эксп-22 → его же клетки
if u["uid"] in new_uids:
return PAN.text_b(arm, u["uid"])
# ⚠ R0 — боевой редактор поверх ЯКОРНОГО черновика. В именовании эксп-22 арм
# редактуры назван по ЧЕРНОВИКУ-жильцу, а не по редактору: `edit_text("dspro")` дало
# бы редактуру поверх ЧЕРНОВИКА dspro, то есть ДРУГУЮ базу, и контраст сравнивал бы
# армы над разными основаниями. Поймано приёмкой. Направление смещения НЕ замерено —
# прежний комментарий утверждал «против R2» без замера, утверждение снято.
return P22.edit_text(PAN.ANCHOR, u["uid"]) if arm == "R0" else _ed22(u["uid"])
if arm == "F":
return PAN.P22.draft_text(PAN.ANCHOR, u["uid"])
return PAN.text_c(arm, u["uid"])
return hook
def _ed22(uid: str) -> str:
"""Клетка R2 эксп-22 — это `glm-5` над якорной базой в его фазе Ф3."""
import editors as ED # noqa: PLC0415
return ED.text_of("R2", uid)
def floor_of(pass_: str):
"""Пол СВОЕГО прохода — две независимые генерации самого дешёвого арма панели.
⚠ Для `border` пол берётся ОТТУДА ЖЕ и это объявлено: армы обоих проходов — редакторы над
одной якорной базой, судит одна и та же популяция, поэтому шум одного вызова у них общий.
Покупать второй такой же пол значило бы платить за то же число дважды.
"""
def hook(u: dict):
return PAN.floor_pair(u["uid"])
return hook
def decoy_base(slots: dict) -> str:
"""⚠ Возвращать ТЕКСТ, а не имя арма. Первая редакция возвращала имя — риг сажал порчу в
строку «R0», получал 0 замен при пороге 2 и МОЛЧА не создавал декой: ноль контролей в обоих
проходах. Это ровно тот отказ, которым эксп-22 потерял проход en, и он воспроизвёлся здесь.
⚠ ДОНОР ПОРЧИ УРАВНОВЕШЕН ПО АРМАМ. Вторая редакция брала R0 всегда (замер: 16/16 и 32/32), а
R0 стоит вторым армом в КАЖДОМ контрасте обоих проходов: если присутствие испорченного близнеца
вообще двигает оценку донора, оно двигает её в одну сторону во всех контрастах сразу, и в
`border` — где контраст один — опровергнуть это нечем. Донор раздаётся РОВНО: единицы
прохода нумеруются по хешу, донор берётся по остатку номера — судье не предсказуем, по проходу
поделён поровну (чистый хеш давал 22/10 на 32 единицах, перекос остался бы). Принятый проход
`tier` отсужен ДО этой правки, его декой во всех 16 единицах взят из R0 — объявленное
ограничение, см. отчёт §12.
"""
battle = [a for a in ("R0", "T1", "T2", "T3", "R2") if (slots.get(a) or "").strip()]
if not battle:
return next(t for t in slots.values() if (t or "").strip())
rank = _DONOR_RANK.get(hashlib.sha1(slots[battle[0]].encode("utf-8")).hexdigest(), 0) # noqa: S324,E501
return slots[battle[rank % len(battle)]]
_DONOR_RANK: dict[str, int] = {}
def _rank_units(pass_: str) -> None:
"""Номер единицы для ровной раздачи донора. Ключ — хеш текста ПЕРВОГО боевого арма: он же
считается в `decoy_base`, куда номер единицы не передаётся. Порядок — по хешу, а не по главе:
соседние главы не должны систематически получать одного донора."""
_DONOR_RANK.clear()
hook = text_of(pass_)
keyed = []
for u in units(pass_):
for a in ("R0", "T1", "T2", "T3", "R2"):
t = hook(a, u) or ""
if t.strip():
keyed.append(hashlib.sha1(t.encode("utf-8")).hexdigest()) # noqa: S324
break
for i, k in enumerate(sorted(keyed)):
_DONOR_RANK[k] = i
def setup(pass_: str, run: str = "") -> None:
"""`run` — имя ОТДЕЛЬНОГО прогона того же прохода (репликация). Пустое = боевой прогон.
⚠ Заведено поправкой фазы Д (чек-лист приёмки №16, п.9). Репликацию гоняли, копируя каталоги
руками: имя каталога выводилось только из `PASS`, поэтому разбор шёл под боевым именем и
проставил голосам репликации имена судей БОЕВОГО жребия — то есть ровно то, что репликация
и меняет. Здесь у прогона свои каталоги и своя карта судей, а ключ/соль/метки остаются
общими (иначе это была бы не репликация, а другой замер).
"""
_rank_units(pass_)
AJ.configure(OUT=OUT, KEYS=KEYS, PASS=pass_, ARMS=arms(pass_), ARMS_2B=(),
FAMILY=family(pass_), TEXT_HOOK=text_of(pass_),
UNITS_HOOK=lambda: units(pass_), FLOOR_HOOK=floor_of(pass_),
DECOY_HOOK=decoy_base, SRC_LANG="китайском",
DIRS_HOOK=None, JUDGES_HOOK=None)
if run:
jmap = json.loads((OUT / f"{run}-JUDGES.json").read_text(encoding="utf-8"))
AJ.configure(
DIRS_HOOK=lambda: (OUT / f"aj-{pass_}-tasks", OUT / f"{run}-answers",
OUT / f"{run}-votes"),
JUDGES_HOOK=lambda tok, _mtime: jmap.get(tok, "?"))
KEYS.mkdir(parents=True, exist_ok=True)
def runs_used() -> list[dict]:
return json.loads(RUNS.read_text(encoding="utf-8")) if RUNS.exists() else []
def log_run(pass_: str, tokens: list[str], note: str = "") -> int:
rs = runs_used()
n = len(rs) + 1
rs.append(dict(n=n, pass_=pass_, tokens=tokens, note=note))
RUNS.write_text(json.dumps(rs, ensure_ascii=False, indent=1), encoding="utf-8")
jf = KEYS / f"{pass_}-JUDGES.json"
m = json.loads(jf.read_text(encoding="utf-8")) if jf.exists() else {}
for t in tokens:
m[t] = f"agent-{n:02d}"
jf.write_text(json.dumps(m, ensure_ascii=False, indent=1), encoding="utf-8")
if n > CAP:
raise SystemExit(f"⛔ КАП АГЕНТ-ЗАПУСКОВ ПРОБИТ: {n} > {CAP}")
return n
def per_run(pass_: str) -> int:
return max(1, 20 // max(1, len(arm_map(pass_)) + 3))
def cmd_plan(pass_: str) -> None:
setup(pass_)
us, amap = units(pass_), arm_map(pass_)
print(f"проход {pass_} · единиц {len(us)} · армов {len(amap)}")
for a, m in amap.items():
print(f" {a:4s}{m}")
print("семейство (в ключ ДО ответов):")
for a, b, w in family(pass_):
print(f" {a:4s} vs {b:4s} {w}")
have = sum(1 for u in us for a in amap if (text_of(pass_)(a, u) or "").strip())
print(f"текстов готово {have} из {len(us) * len(amap)} · "
f"агент-запусков {len(runs_used())} из {CAP}")
if __name__ == "__main__":
a = sys.argv[1:] or ["--plan", "tier"]
if a[0] == "--plan":
cmd_plan(a[1] if len(a) > 1 else "tier")
elif a[0] == "--emit":
setup(a[1])
AJ.emit()
elif a[0] == "--run":
# Разбор/счёт ОТДЕЛЬНОГО прогона: --run <прогон> <проход> [--ingest|--score|--controls]
setup(a[2], run=a[1])
what = a[3] if len(a) > 3 else "--score"
if what == "--ingest":
AJ.ingest()
elif what == "--controls":
AJ.controls()
else:
AJ.score()
elif a[0] == "--ingest":
setup(a[1])
AJ.ingest()
# ⚠ Раздача судей персистируется ЗДЕСЬ. `log_run` был написан и ни разу не вызван: во всех
# голосах пака стоит `judge='?'`, согласие судей посчитать нечем, а «агент-запусков 38» —
# счёт по памяти. Токены сессии передаются списком: --ingest <проход> <ток> <ток> …
if len(a) > 2:
print(f"запуск #{log_run(a[1], a[2:], note='судейская сессия')} записан")
elif a[0] == "--score":
setup(a[1])
AJ.controls()
print()
AJ.score()
else:
print(__doc__)