textmachine/eval/tenant_panel/contam.py

179 lines
8.9 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.2 — ПРОБА КОНТАМИНАЦИИ СРЕЗА. Методика эксп-21 §0, две поправки.
Метод (не менялся): на одном отрывке задаются ДВА безобидных вопроса —
УЗНАВАНИЕ «что это за произведение и кто автор» — ловит знание о книге вообще;
КЛОУЗ из отрывка вырезано имя собственное, назвать пропущенное — ловит знание КОНКРЕТИКИ.
Порог объявлен эксп-21 и здесь не двигается: **узнавание ≥3/5 = материал непригоден**.
Проба на ПРОДОЛЖЕНИЕ негодна как класс (эксп-21 сняла две редакции: современные модели не
воспроизводят текст дословно даже для заведомо заученной книги).
ДВЕ ПОПРАВКИ ПРОТИВ ЭКСП-21:
* **две модели, а не одна.** §4 п.9 эксп-21 прямо оставил открытым, что контаминация мерена
ОДНОЙ моделью, а армы населяют другие. Здесь проба идёт и на `gpt-5.6-luna` (сопоставимость
с эксп-21), и на `deepseek-v4-flash` — боевом жильце черновой роли;
* **два опорных контроля.** Кроме положительного (金瓶梅, минский канон) добавлена 蛊真人 —
книга эксп-21, получившая 4/5. Она превращает мой результат из «ноль» в «ноль ПРОТИВ 4/5 на
том же приборе», то есть отвечает на «а работает ли проба» вторым независимым способом.
⚠ Отрывки в репозиторий и в отчёт не попадают: печатаются вердикты и доли.
"""
from __future__ import annotations
import json
import os
import re
import sys
import time
from collections import Counter
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
ZONE = Path(__file__).resolve().parent
sys.path.insert(0, str(ZONE))
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
from dotenv import load_dotenv # noqa: E402
from openai import OpenAI # noqa: E402
import material as M # noqa: E402
import money as MONEY # noqa: E402
from roster import CANDIDATES # noqa: E402
load_dotenv(REPO / "eval" / ".env")
N_PASSAGES = 5
PRIME_CHARS = 700
PROBES = ("gpt-5.6-luna", "deepseek-v4-flash")
BOOKS: dict[str, dict] = {
"slice22": dict(kind="СРЕЗ ЭКСП-22", lang="zh",
titles=["阴阳天帝诀", "yin yang tian di", "инь ян"]),
"gu-zhenren": dict(kind="опора: книга эксп-21 (там 4/5)", lang="zh",
path=Path.home() / "books" / "gu-zhenren" / "guzhenren-utf8.txt",
titles=["蛊真人", "гу чжэнь", "reverend insanity", "гу чжэньжэнь"]),
"jinpingmei": dict(kind="ПОЛОЖИТЕЛЬНЫЙ КОНТРОЛЬ", lang="zh",
path=Path.home() / "books" / "jinpingmei" / "jinpingmei-ctext-zhs.txt",
titles=["金瓶梅", "цзинь пин мэй", "plum in the golden"]),
}
def book_text(name: str) -> str:
if name == "slice22":
return "\n".join(c["text"] for c in M.chapters())
return BOOKS[name]["path"].read_text(encoding="utf-8", errors="ignore")
def frequent_names(text: str, top: int = 40) -> list[str]:
"""Частые CJK-биграммы/триграммы — кандидаты на маскирование.
⚠ Порог частоты масштабируется длиной: у эксп-21 он был константой 20 и на срезе в 40k знаков
отсекал ВСЕ имена, то есть проба молча осталась бы без клоуза.
"""
need = max(4, len(text) // 4000)
cand: Counter[str] = Counter()
for n in (3, 2):
for m in re.finditer(r"[㐀-鿿]{%d}" % n, text):
cand[m.group(0)] += 1
return [w for w, c in cand.most_common(top * 4) if c > need][:top]
def passages(text: str, names: list[str], n: int) -> list[tuple[str, str]]:
body = text[len(text) // 10: -len(text) // 10]
out: list[tuple[str, str]] = []
for i in range(n * 8):
if len(out) >= n:
break
pos = len(body) * (i + 1) // (n * 8 + 1)
chunk = body[pos:pos + PRIME_CHARS]
if len(chunk) < PRIME_CHARS:
continue
hit = next((w for w in names if w in chunk), None)
if not hit:
continue
out.append((chunk.replace(hit, "[???]"), hit))
return out
SYS = ("Тебе дают отрывок художественного текста, в котором одно имя собственное заменено на "
"[???]. Ответь РОВНО двумя строками и ничем больше:\n"
"ПРОИЗВЕДЕНИЕ: <название и автор, либо НЕ ЗНАЮ>\n"
"ИМЯ: <что стоит вместо [???], либо НЕ ЗНАЮ>\n"
"Не пересказывай отрывок и не продолжай его.")
def client(model: str) -> OpenAI:
base, env, *_ = CANDIDATES[model]
return OpenAI(api_key=os.environ[env], base_url=base, timeout=300)
def ask(led, model: str, prime: str, tag: str) -> dict:
msgs = [{"role": "system", "content": SYS}, {"role": "user", "content": prime}]
kw: dict = dict(model=model, messages=msgs)
if model in ("gpt-5.6-luna",):
kw["max_completion_tokens"] = 400
kw["extra_body"] = {"reasoning_effort": "none"}
else:
# DeepSeek: thinking НЕ гасим (эхо-мина), но эффорт обязателен — иначе стена quirks §10.
kw["max_tokens"] = 3000
kw["extra_body"] = {"reasoning_effort": "low"}
return MONEY.purchase(led, tag, model, client(model), kw, probe="contamination")
def parse(answer: str) -> tuple[str, str]:
work = name = ""
for line in (answer or "").splitlines():
if line.upper().startswith("ПРОИЗВЕДЕНИЕ"):
work = line.split(":", 1)[-1].strip()
elif line.upper().startswith("ИМЯ"):
name = line.split(":", 1)[-1].strip()
return work, name
def main() -> None:
dry = "--dry" in sys.argv
led = MONEY.Guarded("Ф0", default_expect=0.004)
rows = []
for book, meta in BOOKS.items():
if book != "slice22" and not meta["path"].exists():
print(f"{book}: файла нет — пропуск")
continue
text = book_text(book)
ps = passages(text, frequent_names(text), N_PASSAGES)
print(f"\n{book} ({len(text):,} знаков) — отрывков {len(ps)} · {meta['kind']}")
if dry:
continue
for model in PROBES:
rec_ok = cloze_ok = n = 0
for i, (prime, hidden) in enumerate(ps):
r = ask(led, model, prime, f"tp0-cont-{book}-{model}-{i}")
if r.get("skipped"):
print(" ⛔ потолок Ф0 — стоп")
return
work, name = parse(r.get("content", ""))
r_hit = any(t in work.lower() for t in meta["titles"])
c_hit = bool(name) and "НЕ ЗНАЮ" not in name.upper() and \
(hidden in name or name in hidden)
rec_ok += r_hit
cloze_ok += c_hit
n += 1
time.sleep(0.2)
rows.append(dict(book=book, model=model, n=n, recognized=rec_ok, cloze=cloze_ok,
kind=meta["kind"]))
print(f" {model:22s} узнавание {rec_ok}/{n} · клоуз {cloze_ok}/{n}")
if dry:
return
print(f"\n{'книга':14s}{'модель':22s}{'узнано':>8s}{'клоуз':>7s} вердикт (порог: узнавание ≥3/5)")
print("-" * 86)
for r in rows:
v = ("НЕПРИГОДЕН" if r["recognized"] * 5 >= 3 * max(1, r["n"]) else
"знает конкретику" if r["cloze"] >= 2 else "следов нет")
print(f"{r['book']:14s}{r['model']:22s}{r['recognized']:>4d}/{r['n']:<3d}"
f"{r['cloze']:>4d}/{r['n']:<2d} {v}")
(MONEY.OUT / "contamination.json").write_text(
json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8")
print(f"\nпотрачено Ф0 ${led.spent():.6f} из ${led.ceiling:.2f}")
if __name__ == "__main__":
main()