textmachine/eval/dovodka/contam_d.py

268 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
"""КОНТАМИНАЦИОННАЯ ПРОБА ФАЗЫ Д — ЖИЛЬЦАМИ и на книгах, которые фаза реально покупает. Платный.
Заказ (Д3 и Д6) требует пробу «ЖИЛЬЦАМИ» — правило эксп-22 §1.1: гонять теми, кто работает,
а не удобной моделью. Непригоден по пробе → СТОП и пинг, замену без слова владельца не искать.
Риг НЕ пере-изобретается: берётся `eval/role_topology/contamination.py` и настраивается через
свои глобали — тем же приёмом, каким `money_d` настраивает кассу, а `sud` — судейский риг. Метод
пробы остаётся его: УЗНАВАНИЕ («что за произведение и кто автор») плюс КЛОУЗ ПО ИМЕНИ (из отрывка
вырезано имя собственное, модель называет пропущенное). Пороги — его же, объявленные до данных:
узнавание ≥3/5 = «знает о книге», клоуз ≥2/5 = «знает конкретику, то есть текст был в обучении».
⚠ ЧТО ЗДЕСЬ ДОБАВЛЕНО И ЗАЧЕМ.
1. **Книги фазы.** Прошлый прогон мерил `gu-zhenren`, `jinpingmei`, `fifty_shades`, `kristoff` и
СТАРУЮ японскую книгу. Новая японская (`enkan_no_hate`, замена по слову владельца после
срабатывания гардрейла 18+) не мерена ВООБЩЕ, а Д6 покупается именно на ней.
2. **Жильцы вместо удобной модели.** Прошлый прогон гнался `gpt-5.6-luna`. Это не жилец: черновики
делает `deepseek-v4-flash`, редактуру `deepseek-v4-pro`. Знание книги ИМИ и есть то, что
завышает качество перевода, а знание посторонней модели не значит ничего.
3. **Бюджет вывода поднят.** Редакция 1 прошлой пробы звала flash с 16000 токенов и получала
046 знаков: у DeepSeek размышление тарифицируется и считается ВНУТРИ бюджета, поэтому на
короткий ответ ничего не оставалось (стена quirks §10; ровно эта же стена сегодня съела 14
клеток арма A1B). Ответ здесь нужен КОРОТКИЙ, а думать модель будет много — значит бюджет
должен вмещать размышление ЦЕЛИКОМ, иначе «НЕ ЗНАЮ» будет означать «не успел сказать».
⚠ Если и при поднятом бюджете жилец возвращает пустоту — это НЕ «книга чиста», а непригодность
пробы на этом жильце, и она объявляется отдельно, а не растворяется в нуле.
4. **Положительный контроль обязателен на КАЖДОМ жильце.** Нулевой результат неинтерпретируем:
«модель не знает книгу» и «проба не работает на этой модели» дают одну картину. Контроль —
`jinpingmei` (минский канон, public domain, заведомо в претрейне). Не сработал контроль —
аннулируется весь прогон этого жильца, а не отдельная книга.
Запуск: contam_d.py --dry # план, $0
contam_d.py # платный
"""
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", "role_topology", "tenant_panel"):
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
MONEY = _load("money_d", ZONE / "money_d.py")
CT = _load("contam21", REPO / "eval" / "role_topology" / "contamination.py")
ROSTER = _load("roster_c", REPO / "eval" / "tenant_panel" / "roster.py")
ED = _load("editors_c", REPO / "eval" / "tenant_panel" / "editors.py")
# ⚠⚠ КАССУ ПЕРЕ-НАСТРАИВАЕМ ПОСЛЕ ВСЕХ ЧУЖИХ ЗАГРУЗОК — та же мина, что описана в `contour.py`.
# `buy` — СИНГЛТОН в `sys.modules`, и побеждает тот, кто настроил его последним: `contamination`
# и `editors` тянут кассу эксп-21/22 и перебивают `BUY.OUT` обратно на `~/books/tenant-panel`.
# Отказ бесшумный и дорогой — проверено исполнением 14.08: 7 клеток пробы ушли в ЧУЖОЙ каталог,
# касса фазы показывала $0, а гейт потолка сторожил границу, к которой покупки не относились.
MONEY.M22.configure(ZONE=ZONE, OUT=MONEY.OUT, TAGS=MONEY.TAGS, CEILINGS=MONEY.CEILINGS,
PACK_CEILING=MONEY.PACK_CEILING, GLOB_ALL="dv-*.json", ROSTER=ROSTER)
if MONEY.M22.BUY.OUT != MONEY.OUT: # ассерт, а не надежда
raise SystemExit(f"⛔ buy.OUT={MONEY.M22.BUY.OUT} против кассы {MONEY.OUT}"
"покупки уедут в чужой каталог, СТОП")
# ⚠ БЮДЖЕТ ВЫВОДА. У DeepSeek размышление тарифицируется и считается ВНУТРИ бюджета, поэтому
# короткий ответ при тесном капе не помещается вовсе: прошлая редакция звала flash с 16000 и
# получала 046 знаков. Ответ здесь нужен в две строки, думать модель будет много — кап должен
# вмещать размышление ЦЕЛИКОМ, иначе пустота будет означать «не успел сказать», а не «не знаю».
MAX_OUT = 32000
# ⚠ Кап ДО-КУПКИ усечённых клеток. У `deepseek-v4-pro` две клетки английской книги вернулись
# пустыми с `finish=length`: размышление съело все 32000 и на две строки ответа не осталось.
# Пустая клетка — не «книга чиста», а ОТСУТСТВИЕ данных (правило шапки), и решение о покупке
# несущей оси по 3 клеткам из 5 принимать нельзя. Усечённые уводятся в карантин `.LENGTH.json`
# (деньги остаются видимы кассе) и покупаются заново с этим капом. Девиация объявлена.
MAX_OUT_REFILL = 60000
# Глушить thinking у DeepSeek ЗАПРЕЩЕНО (эхо-мина, гардрейл проекта). Конфигурацию на модель
# даёт `roster.call_kwargs` — своих `reasoning_effort` здесь не ставим НИКОГДА.
SYS = ("Тебе дают отрывок художественного текста, в котором одно имя собственное заменено на "
"[???]. Ответь РОВНО двумя строками и ничем больше:\n"
"ПРОИЗВЕДЕНИЕ: <название и автор, либо НЕ ЗНАЮ>\n"
"ИМЯ: <что стоит вместо [???], либо НЕ ЗНАЮ>\n"
"Не пересказывай отрывок и не продолжай его.")
# Жильцы: те, кто реально работает на парах. Проба на каждом отдельно.
RESIDENTS = ("deepseek-v4-flash", "deepseek-v4-pro")
# Книги, которые фаза Д покупает, плюс обязательный положительный контроль.
BOOKS_D = {
"enkan-ja": dict(path=Path.home() / "books" / "enkan_no_hate_ja.txt", lang="ja",
titles=["円環の果てに", "enkan", "円環", "круг", "предел круга"],
note="ja-ось Д6; первая публикация 2026-07-30, 18 глав — НЕ мерена ни разу"),
"kristoff-en": dict(path=Path.home() / "books" /
"Kristoff_Jay_-_Empire_of_the_Vampire_3_-_Empire_of_the_Dawn.epub",
lang="en", titles=["empire of the dawn", "empire of the vampire",
"кристофф", "kristoff", "империя вампиров",
"империя рассвета", "империя зари"],
note="en-ось Д3; luna узнала СЕРИЮ 1 раз из 5 — жильцами не мерена"),
"jinpingmei": dict(path=Path.home() / "books" / "jinpingmei" / "jinpingmei-ctext-zhs.txt",
lang="zh", titles=["金瓶梅", "золотой лотос", "jin ping mei", "цзинь пин мэй",
"цветы сливы", "ланьлин", "насмешник"],
note="ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ — обязателен на каждом жильце"),
}
# Книга → фаза кассы. Положительный контроль относим к ФД-B: он покупается один раз, а прогон
# Д6 возьмёт те же теги из кэша за $0.
PHASE = {"kristoff-en": "ФД-B", "jinpingmei": "ФД-B", "enkan-ja": "ФД-C"}
# ⚠ СЧЁТЧИК СВЕРЯЛ НЕ ТО. Найдено ЧТЕНИЕМ всех 30 ответов 14.08 — прибор печатал числа, которые
# не соответствуют содержимому клеток. Причина: сверка шла подстрокой со списком названий на
# языке оригинала, а модели отвечают ПО-РУССКИ. Цена ошибки была двусторонней и обе стороны
# опасны: контроль pro на «Цзинь пин мэй» — 5 верных ответов из 5 — печатался как 1/5 (то есть
# проба выглядела бы неработающей и аннулировала бы жильца), а на английской книге ТОЧНОЕ
# попадание «Империя вампиров, Джей Кристофф» + «Габриэль де Леон» при вырезанном `Gabriel`
# печаталось как клоуз-промах, потому что `Gabriel` не подстрока «Габриэль». Второе страшнее:
# так контаминация несущей оси спряталась бы под нулём.
# ПОРОГИ НЕ ТРОНУТЫ (узнавание ≥3/5, клоуз ≥2/5) — чинится только сопоставление.
_RU2LAT = {"щ": "sch", "ш": "sh", "ч": "ch", "ж": "zh", "ю": "yu", "я": "ya", "ё": "yo",
"э": "e", "х": "h", "ц": "c", "а": "a", "б": "b", "в": "v", "г": "g", "д": "d",
"е": "e", "з": "z", "и": "i", "й": "y", "к": "k", "л": "l", "м": "m", "н": "n",
"о": "o", "п": "p", "р": "r", "с": "s", "т": "t", "у": "u", "ф": "f", "ы": "y",
"ь": "", "ъ": ""}
def _lat(s: str) -> str:
return "".join(_RU2LAT.get(c, c) for c in s.lower())
def same_name(gold: str, ans: str) -> bool:
"""Назвал ли ответ то же имя, что вырезано. Терпит русскую передачу латинского имени.
Не «похожие строки», а именно передача: сравниваются ЛАТИНСКИЕ скелеты, и порог сходства
держится высоким, чтобы «Габриэль»≈`Gabriel` проходило, а «Габриэль»≈`Gabrio` — нет.
"""
import difflib # noqa: PLC0415
g, a = gold.strip().lower(), (ans or "").strip().lower()
if not g or not a or _is_refusal(a):
return False
if g in a: # точное вхождение (в т.ч. CJK)
return True
gl = _lat(g)
return any(difflib.SequenceMatcher(None, gl, _lat(tok)).ratio() >= 0.75
for tok in re.findall(r"[\w\u0400-\u04ff]+", a))
def _is_refusal(a: str) -> bool:
"""⚠ «не знаю» ОТВЕТОМ, а не оговоркой. Проверка подстрокой отбрасывала верный ответ
«金瓶梅, автор Ланьлинский насмешник (не знаю точно)»: книга названа правильно, неуверенность
относится к автору. Так контроль занижался, а занижённый контроль аннулирует жильца."""
return a.startswith("не знаю") or a.strip("«»\"'. ") == "не знаю"
def same_work(titles: list[str], ans: str) -> bool:
a = (ans or "").strip().lower()
if not a or _is_refusal(a):
return False
return any(t.lower() in a for t in titles)
PREFIX = {"ФД-B": "dv-b", "ФД-C": "dv-c"}
def probe(res: str, book: str, dry: bool, refill: bool = False) -> dict:
"""Проба одного жильца на одной книге. Возвращает счёт узнавания и клоуза."""
m = BOOKS_D[book]
text = CT.read_book(m["path"])
names = CT.frequent_names(text, m["lang"])
ps = CT.passages(text, names, CT.N_PASSAGES)
ph = PHASE[book]
led = MONEY.Guarded(ph, default_expect=0.01)
rec_hits = cloze_hits = empty = 0
for i, (prime, gold) in enumerate(ps, 1):
tg = f"{PREFIX[ph]}-contam-{res.replace('.', '')}-{book}-{i}"
if dry:
continue
cap = MAX_OUT
f = MONEY.OUT / f"{tg}.json"
if refill and f.exists():
rec0 = json.loads(f.read_text(encoding="utf-8"))
if rec0.get("finish") == "length" and not (rec0.get("content") or "").strip():
f.rename(MONEY.OUT / f"{tg}.LENGTH.json")
cap = MAX_OUT_REFILL
print(f" до-купка {tg}: усечённая клетка в карантин, кап {cap}")
msgs = [{"role": "system", "content": SYS}, {"role": "user", "content": prime}]
r = MONEY.purchase(led, tg, res, ED.client(res),
ROSTER.call_kwargs(res, msgs, max_out=cap))
if r.get("skipped"):
raise SystemExit(f"⛔ ГАРД ОТКАЗАЛ на {tg} — СТОП и вопрос владельцу")
ans = r.get("content") or ""
if not ans.strip():
empty += 1
continue
work, name = CT.parse(ans)
rec_hits += same_work(m["titles"], work)
cloze_hits += same_name(gold, name)
return dict(recognition=rec_hits, cloze=cloze_hits, empty=empty, n=len(ps))
def main() -> int:
dry = "--dry" in sys.argv
print("КОНТАМИНАЦИОННАЯ ПРОБА ФАЗЫ Д — жильцами\n")
print(f"{'книга':14s}{'язык':>6s} что это")
for b, m in BOOKS_D.items():
ok = "" if m["path"].exists() else " ⛔ ФАЙЛА НЕТ"
print(f"{b:14s}{m['lang']:>6s} {m['note']}{ok}")
print(f"\nжильцы: {' · '.join(RESIDENTS)}")
print("пороги (объявлены ДО данных, взяты из рига эксп-21): "
"узнавание ≥3/5 = знает о книге · клоуз ≥2/5 = знает конкретику")
only = [a.split("=", 1)[1] for a in sys.argv if a.startswith("--books=")]
books = only[0].split(",") if only else list(BOOKS_D)
print(f"\nкниги прогона: {' · '.join(books)}")
if dry:
print("--dry: покупок нет; каждая книга = 5 отрывков × 2 жильца")
return 0
res_tbl: dict[tuple[str, str], dict] = {}
for res in RESIDENTS:
for b in books:
res_tbl[(res, b)] = probe(res, b, dry=False, refill="--refill" in sys.argv)
print(f"\n{'жилец':20s}{'книга':14s}{'узнавание':>11s}{'клоуз':>8s}{'пусто':>7s} вердикт")
bad = 0
for (res, b), v in res_tbl.items():
known = v["recognition"] >= 3
concrete = v["cloze"] >= 2
verdict = ("ЗНАЕТ ТЕКСТ" if concrete else "знает о книге" if known else "чисто")
print(f" {res:18s}{b:14s}{v['recognition']:>7d}/{v['n']}{v['cloze']:>6d}/{v['n']}"
f"{v['empty']:>7d} {verdict}")
if b != "jinpingmei" and concrete:
bad += 1
print("\n=== ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ (без него нулевой результат неинтерпретируем) ===")
for res in RESIDENTS:
c = res_tbl.get((res, "jinpingmei"))
if not c:
print(f" {res}: контроль не гнался — прогон этого жильца НЕИНТЕРПРЕТИРУЕМ")
bad += 1
continue
ok = c["recognition"] >= 3
# ⚠ Пустота при поднятом капе — НЕ «книга чиста», а непригодность пробы на этом жильце.
if c["empty"] == c["n"]:
print(f" {res}: ПУСТЫЕ ОТВЕТЫ на контроле — проба на этом жильце НЕПРИГОДНА, "
"нули по остальным книгам не читать")
bad += 1
elif not ok:
print(f" {res}: контроль ПРОВАЛЕН ({c['recognition']}/{c['n']}) — прогон жильца "
"аннулируется целиком, а не отдельная книга")
bad += 1
else:
print(f" {res}: контроль сработал ({c['recognition']}/{c['n']}) — нули значимы")
if bad:
print("\n⛔ проба даёт СТОП: см. строки выше. Замену материала без слова владельца "
"не ищем (заказ Д3/Д6).")
else:
print("\n[OK ] материал фазы пригоден: жильцы конкретики книг не знают")
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())