Add the phase D judging harness: floor halves split across sessions, a margin decoy sized by measurement, isolated task dir and a transcript audit
This commit is contained in:
parent
2fed3d060f
commit
18a8179eb3
2 changed files with 295 additions and 3 deletions
|
|
@ -73,7 +73,7 @@ FORCED_DESCRIPTIVE = {"Д6 ja→ru разведка"} # объявлено
|
|||
# печатаются с числами и поправку не несут. Ровно этот приём эксп-22 применил в §0-Ф2 к осям
|
||||
# «черновик как текст», и он там сработал.
|
||||
PRIMARY = {
|
||||
"Д4 edit-контур zh": ["A1/A0 — флаговый контур (H-2а)",
|
||||
"Д4 edit-контур zh": ["A1B/A0 — флаговый контур: батарея + канон-гейт (H-2а)",
|
||||
"A3/A0 — смысловой контур (H-2б, ставка владельца)",
|
||||
"A6/A0 — качественный черновик + контур (H-1)"],
|
||||
"Д3 en→ru несущая": ["E0/D0 — КОНТРОЛЬ: покупает ли редактор поверх черновика (H-3)"],
|
||||
|
|
@ -87,7 +87,7 @@ PRIMARY = {
|
|||
# Ровно этот класс («ось объявлена несущей, контраст недомощен») погубил en-ось эксп-22.
|
||||
# Числа ниже — фактические выборки после исключения единицы с эхо-мином (contour.EXCLUDED).
|
||||
CONTRAST_N = {
|
||||
"A1/A0 — флаговый контур (H-2а)": 31,
|
||||
"A1B/A0 — флаговый контур: батарея + канон-гейт (H-2а)": 31,
|
||||
"A3/A0 — смысловой контур (H-2б, ставка владельца)": 31,
|
||||
"A6/A0 — качественный черновик + контур (H-1)": 16,
|
||||
"E0/D0 — КОНТРОЛЬ: покупает ли редактор поверх черновика (H-3)": 16,
|
||||
|
|
@ -95,7 +95,8 @@ CONTRAST_N = {
|
|||
}
|
||||
|
||||
SECONDARY = {
|
||||
"Д4 edit-контур zh": ["A2/A0 — однопроходка уравненного мандата (строка 153 бэклога)",
|
||||
"Д4 edit-контур zh": ["A1/A0 — ТОЛЬКО канон-флаги: батарея в первой редакции падала (эррата Э-4)",
|
||||
"A2/A0 — однопроходка уравненного мандата (строка 153 бэклога)",
|
||||
"A4/A0 — канон-фиксер ПОСЛЕ переписа: его несущая ось — покрытие "
|
||||
"канона, а оно детерминировано и шума не имеет"],
|
||||
"Д3 en→ru несущая": ["E1..E5/E0 — армы контуров и однопроходки",
|
||||
|
|
|
|||
291
eval/dovodka/sud.py
Normal file
291
eval/dovodka/sud.py
Normal file
|
|
@ -0,0 +1,291 @@
|
|||
#!/usr/bin/env python3
|
||||
"""СУДЕЙСТВО ФАЗЫ Д. $0 — генерация заданий и разбор ответов, платных вызовов нет.
|
||||
|
||||
Судей ДВА и оба бесплатны (решение владельца 11.08 дословно: «НИКАКИХ ВЫЗОВОВ ПО АПИ, всё
|
||||
локально у нас на харнесах»): агент-сессии и локальный харнесс Sol 5-6 владельца — второе мнение
|
||||
о судействе первого и о его слепоте. Правило расхождения пре-регистрировано в Д0.13 П-1 и здесь
|
||||
не меняется: расхождение разрешается АДЪЮДИКАЦИЕЙ находок, а не выбором удобного семейства.
|
||||
|
||||
Риг не пере-изобретается: `absjudge` эксп-21 настраивается через `configure` — тем же приёмом,
|
||||
которым пользовались паки 22 и 23. Методика (абсолютная шкала · слепые метки · декой · пол ·
|
||||
обоснование цитатой · семейство, впечатанное в ключ ДО ответов · Холм) остаётся общей.
|
||||
|
||||
⚠⚠ ТРИ МЕСТА, ГДЕ ФАЗА Д ОТСТУПАЕТ ОТ УНАСЛЕДОВАННОГО РИГА, И ПОЧЕМУ.
|
||||
|
||||
1. ПОЛ РАЗВОДИТСЯ ПО РАЗНЫМ ПАЧКАМ. `absjudge.build_unit` кладёт обе половины пола (`CTRLfloorA`
|
||||
и `CTRLfloorB`) в ОДНУ пачку — значит их судит одна сессия, и межсессионная компонента в пол
|
||||
не попадает вовсе. Ровно так эксп-23 занизил порог различимости на 19% (§12б). Норма Д0.6
|
||||
требует обратного: «шумовой пол — парой, чьи половины судят РАЗНЫЕ сессии». Поэтому здесь
|
||||
пачки выпускаются ДВУМЯ наборами: `П1` несёт первую половину каждой пары, `П2` — вторую, и
|
||||
наборы отдаются разным сессиям. Вторая норма («все армы единицы — в одном пакете») при этом
|
||||
не нарушается: боевые армы целиком лежат в каждом наборе.
|
||||
|
||||
2. ДЕКОЕВ ДВА, А НЕ ОДИН. Грубый (`CTRLdecoy`, штатный механизм рига) аннулирует семейство, если
|
||||
не пойман. Маржевый (`CTRLmargin`, заведён фазой Д по П-2) несёт ДВЕ ТОНКИЕ посадки классов,
|
||||
реально пропущенных судьёй в проходе `tier`. Он проверяет то, чего грубый не проверяет вовсе:
|
||||
способность прибора видеть эффект РАЗМЕРОМ С МАРЖУ эквивалентности. Без него вывод «не хуже»
|
||||
неотличим от слепоты прибора — это assay sensitivity, требование, без которого вердикт
|
||||
non-inferiority пуст. **Семейство, чей перевес на маржевом декое не пересекает его собственный
|
||||
порог, теряет право говорить «не хуже» и понижается до описательного.**
|
||||
Маржевый декой заводится ПСЕВДО-АРМОМ через `TEXT_HOOK`, а не правкой `absjudge`: рig общий с
|
||||
паками 21–23, и правка его тела потребовала бы пере-снятия их гейтов.
|
||||
|
||||
3. ИЗОЛЯЦИЯ ПЛЮС ЖУРНАЛ, А НЕ ОДНА ИЗОЛЯЦИЯ. Пре-рег Д0.12 писал, что постфактум-детектора
|
||||
подглядывания не существует. Для внешнего харнесса это верно, для агент-сессий — НЕТ, и это
|
||||
проверено исполнением: оплаченная клетка несёт поле `arm` открытым текстом рядом с `content`,
|
||||
поэтому слепая метка деанонимизируется ОДНИМ грепом по фрагменту варианта; а транскрипт
|
||||
агент-сессии логирует вызовы инструментов, то есть аудит «куда лазил судья» возможен. Поэтому
|
||||
задания уходят в каталог, где нет ничего кроме заданий, и после каждой пачки гоняется
|
||||
греп-аудит транскриптов (`--audit`).
|
||||
|
||||
Запуск: sud.py --selftest | --emit | --audit <каталог-транскриптов> | --floor
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import re
|
||||
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
|
||||
|
||||
|
||||
C = _load("contour", ZONE / "contour.py")
|
||||
import absjudge as AJ # noqa: E402
|
||||
|
||||
# ⚠ РАБОЧИЙ КАТАЛОГ СУДЬИ — ВНЕ `~/books/dovodka`. Рядом с заданиями не должно лежать ни ключа
|
||||
# слепых меток, ни сырья с полем `arm`, ни кода. Ключи живут ОТДЕЛЬНО и судье не выдаются.
|
||||
WORK = Path.home() / "sud-d4"
|
||||
TASKS1, TASKS2 = WORK / "p1-tasks", WORK / "p2-tasks"
|
||||
ANSW1, ANSW2 = WORK / "p1-answers", WORK / "p2-answers"
|
||||
VOTES = WORK / "votes"
|
||||
KEYS = Path.home() / "books" / "dovodka" / "blind-keys-d4" # НЕ в WORK: судья туда не ходит
|
||||
|
||||
# Армы панели Д4. `A1` (только канон-флаги) — описательный, в первичное семейство не входит.
|
||||
ARMS_D4 = ("A0", "A1B", "A2", "A3", "A4", "A1")
|
||||
ARMS_OLD = ("A6", "A6F") # есть только у 16 старых единиц
|
||||
|
||||
# ⚠ ПЕРВИЧНОЕ СЕМЕЙСТВО — по одному контрасту на гипотезу владельца, Холм по трём. Объявлено в
|
||||
# пре-реге Д0.5 и `power.py`; здесь оно ПОВТОРЕНО, а не переопределено, и впечатывается в ключ
|
||||
# до первого ответа судьи.
|
||||
FAMILY_D4 = [
|
||||
("A1B", "A0", "флаговый контур против переписа (H-2а)"),
|
||||
("A3", "A0", "смысловой контур против переписа (H-2б, ставка владельца)"),
|
||||
("A6", "A0", "качественный черновик + контур против переписа (H-1)"),
|
||||
]
|
||||
|
||||
# ⚠ МАРЖЕВЫЕ ПОСАДКИ — ТОНКИЕ, и это их суть. Грубые посадки рига (`один`→`сорок`, выдуманное
|
||||
# слово, сломанная вёрстка) ловятся 32/32 и говорят только о потолочном режиме. Классы ниже
|
||||
# выбраны по тому, что судья РЕАЛЬНО пропустил в проходе `tier`: смысл переворачивался, а оценка
|
||||
# была ноль. Замена правдоподобна: текст остаётся связным и грамотным, ошибка — только смысловая.
|
||||
MARGIN_PLANTS = [
|
||||
# (что ищем, чем заменяем, класс) — по одной замене каждого класса, не больше.
|
||||
# ⚠ КЛАССЫ ВЫБРАНЫ ЗАМЕРОМ, А НЕ ВООБРАЖЕНИЕМ. Первая редакция сажала «потерянный разряд
|
||||
# числа» регексом по цифрам — и не сработала НИ РАЗУ на 31 единице: русская художественная
|
||||
# проза пишет числа СЛОВАМИ, цифр в ней нет вовсе (замер: 0 вхождений `\d{2,}` на 31 тексте).
|
||||
# Посадка, которая не сажается, превращает «не пойман» в «не было» и делает контроль пустым —
|
||||
# ровно тем, чем был контроль в паке 23. Ниже три класса, замеренные на этом же сырье:
|
||||
# снятие отрицания 31/31 единиц (151 вхождение) · подмена числительного 26/31 (108) ·
|
||||
# инверсия сравнения 20/31 (33). Все три — тонкие: меняется одно слово, текст остаётся
|
||||
# связным и грамотным, ошибка ТОЛЬКО смысловая. Это и есть маргинальный режим, ради которого
|
||||
# декой заведён: грубые посадки рига ловятся 32/32 и о нём не говорят ничего.
|
||||
[r"\bне\s+([а-яё]{3,}(?:л|ла|ло|ли|ет|ит))\b", r"\1", "снято отрицание — смысл инвертирован"],
|
||||
[r"\b(три|четыре|пять|шесть|семь|восемь|девять)\b", "двенадцать", "подмена числительного"],
|
||||
[r"\b(больше|выше|сильнее)\b", "меньше", "инверсия сравнения"],
|
||||
]
|
||||
MARGIN_MIN = 2 # клетка, где посадить удалось меньше, в маржевый декой не идёт
|
||||
|
||||
|
||||
def units():
|
||||
"""Единицы, допустимые к судейству: без брака базы и без клеток finish=length."""
|
||||
return C.units_ok()
|
||||
|
||||
|
||||
def text_of(arm: str, u: dict) -> str:
|
||||
"""Текст арма. Клетка, не прошедшая гейт годности, в пачку НЕ идёт — возвращается пусто."""
|
||||
uid = u["uid"]
|
||||
if arm == "A0":
|
||||
return C.base_a0(uid)
|
||||
if arm == "CTRLmargin":
|
||||
planted, n = margin_plant(C.base_a0(uid))
|
||||
return planted if n >= MARGIN_MIN else ""
|
||||
f = C.OUT / f"{C.tag(arm, uid)}.json"
|
||||
if not f.exists():
|
||||
return ""
|
||||
d = json.loads(f.read_text(encoding="utf-8"))
|
||||
# ⚠ ГЕЙТ ПАЧКИ (норма Д0.6): клетка с `finish=length` не допускается ни боевым армом, ни
|
||||
# половиной пола — её выход оборван на середине предложения, и судья оценит обрыв, а не арм.
|
||||
if d.get("finish") != "stop":
|
||||
return ""
|
||||
return d.get("content") or ""
|
||||
|
||||
|
||||
def margin_plant(text: str) -> tuple[str, int]:
|
||||
"""Тонкая контролируемая порча. Возвращает (текст, число РЕАЛЬНО применённых замен)."""
|
||||
out, n = text, 0
|
||||
for pat, rep, _c in MARGIN_PLANTS:
|
||||
new, cnt = re.subn(pat, rep, out, count=1)
|
||||
if cnt:
|
||||
out, n = new, n + cnt
|
||||
return out, n
|
||||
|
||||
|
||||
def floor_pair(u: dict, half: int) -> str:
|
||||
"""ОДНА половина пары пола. Наборы П1/П2 берут разные половины — см. отступление 1 в шапке."""
|
||||
f = C.OUT / f"dv-a-flo{half}-{u['uid']}.json"
|
||||
if not f.exists():
|
||||
return ""
|
||||
d = json.loads(f.read_text(encoding="utf-8"))
|
||||
return (d.get("content") or "") if d.get("finish") == "stop" else ""
|
||||
|
||||
|
||||
def setup(half: int) -> None:
|
||||
"""Настроить общий риг на панель Д4 и на НАБОР `half` (1 или 2).
|
||||
|
||||
⚠ Крючки сбрасываются ЯВНО все до одного. `absjudge` держит их глобалями, и после работы с
|
||||
чужим паком касса и каталоги молча читали чужое — этот класс стоил паку 22 пересчёта
|
||||
32 единиц вместо 16 (дефект D-3). Поэтому здесь перечислено ВСЁ, а не только изменяемое.
|
||||
"""
|
||||
old = {u["uid"] for u in C.P22.M.units_zh()}
|
||||
arms = ARMS_D4 + ARMS_OLD + ("CTRLmargin",)
|
||||
t, a = (TASKS1, ANSW1) if half == 1 else (TASKS2, ANSW2)
|
||||
AJ.configure(
|
||||
PASS=f"d4p{half}",
|
||||
ARMS=arms,
|
||||
FAMILY=FAMILY_D4,
|
||||
SRC_LANG="китайском",
|
||||
UNITS_HOOK=units,
|
||||
TEXT_HOOK=lambda arm, u: ("" if arm in ARMS_OLD and u["uid"] not in old
|
||||
else text_of(arm, u)),
|
||||
# декой сажается в БОЕВОЙ ПЕРЕПИС, и донор объявлен: в паке 23 донором во всех 16 единицах
|
||||
# был один и тот же арм, и это осталось необъявленным до самого разбирательства
|
||||
DECOY_HOOK=lambda slots: slots.get("A0") or "",
|
||||
# пол НЕ через штатный крючок: он кладёт обе половины в одну пачку — см. отступление 1
|
||||
FLOOR_HOOK=lambda u: ((floor_pair(u, half), floor_pair(u, half))
|
||||
if False else None),
|
||||
DIRS_HOOK=lambda: (t, a, VOTES),
|
||||
JUDGES_HOOK=None,
|
||||
)
|
||||
AJ.KEYS = KEYS
|
||||
|
||||
|
||||
def emit(half: int) -> None:
|
||||
"""Выпустить набор пачек. Ключ пишется ДО существования хоть одного ответа."""
|
||||
setup(half)
|
||||
# половина пола входит в набор ОТДЕЛЬНЫМ псевдо-армом, чтобы попасть в ту же пачку, но
|
||||
# в ДРУГУЮ сессию, чем её пара
|
||||
base = AJ.TEXT_HOOK
|
||||
AJ.configure(ARMS=tuple(AJ.ARMS) + ("CTRLfloor",),
|
||||
TEXT_HOOK=lambda arm, u: (floor_pair(u, half) if arm == "CTRLfloor"
|
||||
else base(arm, u)))
|
||||
AJ.emit()
|
||||
print(f"набор П{half}: задания → {(TASKS1 if half == 1 else TASKS2)} · "
|
||||
f"ключ → {KEYS} (судье НЕ давать)")
|
||||
|
||||
|
||||
FORBIDDEN = ("blind-keys", "dv-a-", "dv-f-", "/books/dovodka", "contour.py", "sud.py")
|
||||
|
||||
|
||||
def audit(tr_dir: Path) -> int:
|
||||
"""⚠ ПОСТФАКТУМ-АУДИТ: куда судья ходил инструментами. Существует, вопреки Д0.12."""
|
||||
bad = 0
|
||||
files = sorted(Path(tr_dir).glob("*.jsonl"))
|
||||
if not files:
|
||||
print(f"⛔ транскриптов в {tr_dir} нет — аудит невозможен, пачка НЕ принимается")
|
||||
return 1
|
||||
for f in files:
|
||||
txt = f.read_text(encoding="utf-8", errors="replace")
|
||||
hits = sorted({w for w in FORBIDDEN if w in txt})
|
||||
if hits:
|
||||
bad += 1
|
||||
print(f"[ПРОВАЛ] {f.name}: судья касался запрещённого — {hits}")
|
||||
else:
|
||||
print(f"[OK ] {f.name}: запрещённых путей не касался")
|
||||
print(f"\n{'АУДИТ ЧИСТ' if not bad else f'ПАЧКА НЕ ПРИНИМАЕТСЯ: подозрительных сессий {bad}'}")
|
||||
return bad
|
||||
|
||||
|
||||
def 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 ""))
|
||||
|
||||
us = units()
|
||||
ck("единиц к судейству 31 (32 минус эхо-мина)", len(us) == 31, str(len(us)))
|
||||
u = us[0]
|
||||
# клетки finish=length не должны попадать в пачку ни одним армом
|
||||
lens = [(a, x["uid"]) for a in ARMS_D4 + ARMS_OLD for x in us
|
||||
if (C.OUT / f"{C.tag(a, x['uid'])}.json").exists()
|
||||
and json.loads((C.OUT / f"{C.tag(a, x['uid'])}.json").read_text()).get("finish")
|
||||
!= "stop" and text_of(a, x)]
|
||||
ck("клетка finish=length в пачку не попадает", not lens, str(lens[:2]))
|
||||
# маржевый декой обязан РЕАЛЬНО сажаться, иначе «не пойман» = «не было»
|
||||
ok_m = sum(1 for x in us if text_of("CTRLmargin", x))
|
||||
ck("маржевый декой сажается на большинстве единиц", ok_m >= len(us) * 0.6,
|
||||
f"{ok_m} из {len(us)}")
|
||||
# и обязан быть ТОНКИМ: текст меняется мало, иначе это второй грубый декой
|
||||
import difflib # noqa: PLC0415
|
||||
sims = []
|
||||
for x in us[:8]:
|
||||
m = text_of("CTRLmargin", x)
|
||||
if m:
|
||||
sims.append(difflib.SequenceMatcher(None, C.base_a0(x["uid"]), m,
|
||||
autojunk=False).ratio())
|
||||
ck("маржевый декой ТОНКИЙ (меняет <1% знаков)", sims and min(sims) > 0.99,
|
||||
f"схожесть мин {min(sims):.4f}" if sims else "нет посадок")
|
||||
# грубый декой рига обязан оставаться ГРУБЫМ — иначе оба контроля меряют одно
|
||||
coarse, nc = AJ._plant(C.base_a0(u["uid"])) # noqa: SLF001
|
||||
cs = difflib.SequenceMatcher(None, C.base_a0(u["uid"]), coarse, autojunk=False).ratio()
|
||||
ck("грубый декой ГРУБЕЕ маржевого", nc >= 2 and cs < (min(sims) if sims else 1.0),
|
||||
f"грубый {cs:.4f} против маржевого {min(sims):.4f}" if sims else "")
|
||||
# половины пола идут в РАЗНЫЕ наборы и не совпадают побайтно
|
||||
pairs = [(floor_pair(x, 1), floor_pair(x, 2)) for x in us]
|
||||
pairs = [p for p in pairs if p[0] and p[1]]
|
||||
ck("пар пола не меньше 15", len(pairs) >= 15, str(len(pairs)))
|
||||
ck("половины пола НЕ равны побайтно (иначе контроль пуст)",
|
||||
not [1 for a, b in pairs if a == b])
|
||||
# половина пола не должна совпадать с боевой клеткой (урок «близнеца» пака 23)
|
||||
battle = {text_of(a, x) for a in ARMS_D4 for x in us}
|
||||
ck("половина пола не совпадает с боевым армом",
|
||||
not [1 for a, b in pairs if a in battle or b in battle])
|
||||
# ключи лежат ВНЕ рабочего каталога судьи
|
||||
ck("ключ слепых меток вне каталога заданий",
|
||||
KEYS not in TASKS1.parents and TASKS1 not in KEYS.parents and KEYS.parent != WORK,
|
||||
f"{KEYS} против {WORK}")
|
||||
# семейство первичных контрастов совпадает с объявленным в power.py
|
||||
P = _load("power_d", ZONE / "power.py")
|
||||
declared = {c.split(" — ")[0] for c in P.PRIMARY["Д4 edit-контур zh"]}
|
||||
mine = {f"{a}/{b}" for a, b, _ in FAMILY_D4}
|
||||
ck("семейство совпадает с объявленным в power.py", declared == mine,
|
||||
f"{sorted(declared)} против {sorted(mine)}")
|
||||
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 selftest() else 0)
|
||||
elif a[0] == "--emit":
|
||||
emit(1)
|
||||
emit(2)
|
||||
elif a[0] == "--audit":
|
||||
sys.exit(1 if audit(Path(a[1])) else 0)
|
||||
else:
|
||||
raise SystemExit(f"⛔ неизвестный режим {a[0]!r}; знаю --selftest --emit --audit")
|
||||
Loading…
Add table
Reference in a new issue