251 lines
14 KiB
Python
251 lines
14 KiB
Python
#!/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__)
|