textmachine/eval/role_topology/drift.py

304 lines
18 KiB
Python
Raw Permalink 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
"""Ф2в — ДРЕЙФ НА СВЯЗНОМ ОТРЕЗКЕ. Единственная ось, которой оконный риг слеп ПО ПОСТРОЕНИЮ.
ЗАЧЕМ. Всё, что пак измерил до сих пор, измерено на РАССЕЯННЫХ окнах: 16 несмежных единиц.
Продуктовая цель по канону владельца — «консистентные термины и голоса на ВСЮ КНИГУ». Из
рассеянных окон это не выводится ни при каком числе единиц: чтобы увидеть, расходится ли перевод
термина между главой 3 и главой 11, нужны глава 3 и глава 11 подряд. Независимая оценка замысла
07.08 назвала дрейф единственной покупкой, которую стоит делать оставшимся бюджетом, и
единственной осью, где топологии реально расходятся (переписыватель ломает термины банка,
однопроходка — нет; это и был кризис эксп-20).
ЧТО МЕРИТСЯ. Метрики дрейфа детерминированные, судья на них не нужен:
* КОНСИСТЕНТНОСТЬ ТЕРМИНА — один zh-термин, встречающийся в N главах, обязан переводиться
одинаково. Считается число РАЗНЫХ переводов на термин и доля терминов с расхождением.
* ШОВ — расхождение НА ГРАНИЦЕ глав: термин, переведённый в главе k одним способом, а в k+1
другим. Это дрейф в чистом виде: он накапливается, а не распределён равномерно.
* СТАБИЛЬНОСТЬ ОБРАЩЕНИЯ — «ты» против «вы» в пределах одной пары персонажей по всему отрезку.
* РОСТ СЛОВАРЯ — сколько НОВЫХ вариантов перевода появляется с каждой главой; растущая кривая
означает, что арм не помнит собственных решений.
⚠ ЧЕГО ЭТО НЕ ДЕЛАЕТ. Армы пака состояния между главами НЕ несут — банк им не передаётся. Значит
меряется дрейф БЕЗ памяти, то есть нижняя граница: с банком он может быть только меньше. Это и
есть содержательный вопрос — сколько дрейфа снимает сама топология, до всякой памяти.
Запуск: --plan что будет куплено и почём, $0
--arms купить армы на связном отрезке (под кассой, тег `dr-*`)
--score метрики дрейфа, $0
"""
from __future__ import annotations
import json
import re
import statistics as st
import sys
import time
from collections import defaultdict
from pathlib import Path
REPO = Path("/home/ubuntu/projects/textmachine")
sys.path.insert(0, str(REPO / "eval" / "role_topology"))
import bakeoff as BO # noqa: E402
import buy as BUY # noqa: E402
OUT = Path.home() / "books" / "role-topology"
CORP = Path.home() / "books" / "gu-zhenren" / "labels" / "raw" / "corpus.jsonl"
N_CHAPTERS = 10 # заказ промта: 810
# ⚠ ПОПРАВКА К ФРИЗУ 07.08, внесена ДО чтения боевых чисел дрейфа и с объявленной причиной.
# Первый состав ("F3","A","D_") нёс КОНФАУНД, найденный проверкой сообщений: банк терминов
# подаётся черновику и `D_` (block="law"), а арму `A` — НЕТ (block=None, это боевой бейзлайн
# без банкноты). На оси «консистентность термина» это означало бы замер БАНКА под видом замера
# топологии. `A_law` — тот же редактор С банком — делает сравнение честным.
DRIFT_ARMS = ("F3", "A", "A_law", "D_") # черновик · связка без банка · связка с банком · однопроходка
CEIL_DRIFT = 0.60
# ⚠ Глоб покрывает ТОТ ЖЕ тег, которым идут покупки. Правило заведено после того, как касса
# армов глобила `bo2-*`, а покупки шли тегом `bo4-*`, и $0.29309 прошли мимо потолка.
LED_DRIFT = BUY.Ledger("дрейф Ф2в", CEIL_DRIFT, ("dr-*.json",), default_expect=0.02)
_ZH_NUM = {"": 1, "": 2, "": 3, "": 4, "": 5, "": 6, "": 7, "": 8, "": 9, "": 10}
def _chapter_no(src: str) -> int | None:
m = re.search(r"\s*([一二三四五六七八九十百零\d]+)\s*节", src or "")
if not m:
return None
s = m.group(1)
if s.isdigit():
return int(s)
if s == "":
return 10
if s.startswith(""):
return 10 + _ZH_NUM.get(s[1:2], 0)
if len(s) == 3 and s[1] == "":
return _ZH_NUM.get(s[0], 0) * 10 + _ZH_NUM.get(s[2], 0)
if len(s) == 2 and s[1] == "":
return _ZH_NUM.get(s[0], 0) * 10
return _ZH_NUM.get(s)
def chapters(n: int = N_CHAPTERS) -> list[dict]:
"""Связный отрезок из n ПОДРЯД идущих глав. Детерминированно: самый ранний такой отрезок."""
rows = [json.loads(x) for x in CORP.read_text(encoding="utf-8").splitlines() if x.strip()]
by_no: dict[int, dict] = {}
for r in rows:
k = _chapter_no(r.get("source", ""))
if k and k not in by_no and (r.get("source") or "").strip():
by_no[k] = r
nums = sorted(by_no)
for start in nums:
run = [start + i for i in range(n)]
if all(x in by_no for x in run):
return [dict(no=x, source=by_no[x]["source"],
uid=BO.uid_of(by_no[x]["source"])) for x in run]
raise SystemExit(f"⛔ связного отрезка из {n} глав в корпусе нет")
def cmd_plan() -> None:
ch = chapters()
print(f"СВЯЗНЫЙ ОТРЕЗОК: главы {ch[0]['no']}{ch[-1]['no']}, {len(ch)} подряд\n")
for c in ch:
print(f" гл.{c['no']:<3d} {c['uid'][:8]} {len(c['source']):5d} знаков "
f"{c['source'][:46].replace(chr(10), ' ')}")
per = {"F3": 0.00107, "A": 0.00392, "A_law": 0.00378, "D_": 0.00533}
tot = sum(per[a] * len(ch) for a in DRIFT_ARMS)
print(f"\nармы: {', '.join(DRIFT_ARMS)} · ожидаемая цена ${tot:.4f} из потолка ${CEIL_DRIFT}")
print(f"касса `dr-*` · уже потрачено ${LED_DRIFT.spent():.5f}")
def cmd_arms() -> None:
ch = chapters()
# ⚠ Сообщения черновика строятся ТЕМ ЖЕ кодом, что в бейк-оффе (`run_draft`), включая
# ре-ген при эхо-мине: гардрейл CLAUDE.md запрещает гасить мышление у DeepSeek, а на плотном
# CJK эхо стохастично по вызову. Своя копия сборки означала бы другой арм под тем же именем.
for c in ch:
u = dict(source=c["source"], uid=c["uid"], draft="")
sys_msg, user = BO.Q.render_translator(u["source"])
tms = [t for t in BO.IP.TERMS if t in u["source"]]
msgs = [{"role": "system", "content": sys_msg}]
if tms:
msgs.append({"role": "system", "content": BO.IP.block_for(tms, None, False)})
msgs.append({"role": "user", "content": user})
kw = dict(model=BO.DRAFT_MODEL, messages=msgs, max_tokens=16000,
extra_body={"reasoning_effort": "low"})
ok = False
for attempt in (1, 2, 3):
tag = f"dr-DRAFT-{c['uid']}" + ("" if attempt == 1 else f"-r{attempt}")
r = BUY.purchase(LED_DRIFT, tag, BO.DRAFT_MODEL, BO.client(BO.DRAFT_MODEL), kw,
arm="F3", uid=c["uid"], chapter=c["no"], attempt=attempt)
if r.get("skipped"):
print("⛔ потолок дрейфа")
return
why = BO.draft_reject_reason(r.get("content", ""))
if not why:
ok = True
break
print(f" ⚠ гл.{c['no']} черновик отвергнут ({why}), ре-ген {attempt + 1}")
time.sleep(0.2)
if not ok:
print(f" ⛔ гл.{c['no']}: годного черновика нет после 3 попыток")
time.sleep(0.15)
for arm in ("A", "A_law", "D_"):
for c in ch:
u = dict(source=c["source"], uid=c["uid"], draft=own_draft(c["uid"]))
if not u["draft"] and BO.ARMS[arm]["contract"] not in ("direct", "direct-reflow"):
print(f" ⚠ гл.{c['no']}: годного черновика нет, арм {arm} пропущен")
continue
spec = BO.ARMS[arm]
r = BUY.purchase(LED_DRIFT, f"dr-{arm}-{c['uid']}", spec["model"],
BO.client(spec["model"]),
dict(model=spec["model"], messages=BO.build_msgs(arm, u),
max_tokens=16000, temperature=0.4),
arm=arm, uid=c["uid"], chapter=c["no"])
if r.get("skipped"):
print("⛔ потолок дрейфа")
return
time.sleep(0.15)
print(f"куплено · касса ${LED_DRIFT.spent():.5f} из ${CEIL_DRIFT}")
def own_draft(uid: str) -> str:
"""Черновик, прошедший гейт годности. Пусто — годного нет (ре-гены учтены в цене)."""
best = ""
for f in sorted(OUT.glob(f"dr-DRAFT-{uid}*.json")):
c = json.loads(f.read_text(encoding="utf-8")).get("content", "")
if c and not BO.draft_reject_reason(c):
best = c
return best
def text(arm: str, uid: str) -> str:
if arm == "F3":
return own_draft(uid)
f = OUT / f"dr-{arm}-{uid}.json"
return json.loads(f.read_text(encoding="utf-8")).get("content", "") if f.exists() else ""
# Термины отрезка: имена и понятия, встречающиеся не меньше чем в трёх главах исходника.
_RE_ZH_TERM = re.compile(r"[一-鿿]{2,4}")
def terms(ch: list[dict], min_ch: int = 3) -> list[str]:
seen: dict[str, set[int]] = defaultdict(set)
for c in ch:
for t in set(_RE_ZH_TERM.findall(c["source"])):
seen[t].add(c["no"])
return sorted(t for t, s in seen.items() if len(s) >= min_ch)
def variants(arm: str, ch: list[dict]) -> dict:
"""Какую из ДВУХ конкурирующих русских форм термина арм выбрал в каждой главе.
Банк `inject_probe.TERMS` держит на каждый zh-термин пару соперничающих форм и регулярки для
их опознания. Дрейф — это когда арм в главе k пишет одну форму, а в главе k+1 другую, при
том что zh-термин тот же. Считается детерминированно, судья не нужен.
"""
out: dict[str, dict[int, set]] = {}
for zh, (_v1, _v2, r1, r2) in BO.IP.TERMS.items():
for c in ch:
if zh not in c["source"]:
continue
txt = text(arm, c["uid"])
if not txt.strip():
continue
got = set()
if re.search(r1, txt, re.I):
got.add(0)
if re.search(r2, txt, re.I):
got.add(1)
if got:
out.setdefault(zh, {})[c["no"]] = got
return out
def cmd_score() -> None:
ch = chapters()
print(f"ДРЕЙФ НА СВЯЗНОМ ОТРЕЗКЕ: главы {ch[0]['no']}{ch[-1]['no']}\n")
have = {a: [c for c in ch if text(a, c["uid"]).strip()] for a in DRIFT_ARMS}
print("покрытие отрезка армами:")
for a in DRIFT_ARMS:
miss = [c["no"] for c in ch if c not in have[a]]
print(f" {a:4s} {len(have[a])}/{len(ch)} глав" + (f" · нет: {miss}" if miss else ""))
print("\nу связки нога черновика: глава без годного черновика выпадает целиком.")
print(" Однопроходка этого отказного режима не имеет ПО ПОСТРОЕНИЮ.\n")
# ⚠ МЕТРИКА ИСПРАВЛЕНА ИСПОЛНЕНИЕМ. Первая версия считала, какую из ДВУХ форм банка выбрал
# арм, и давала «расхождений нет» у всех троих. Проверка показала, что ВТОРАЯ форма не
# срабатывает НИ РАЗУ (0 попаданий на 8 терминах × 3 армах × 10 главах), то есть ноль был
# тривиален. При этом у арма есть ТРЕТЬЯ форма, банку неизвестная: 蛊师 передаётся то как
# «гу-мастер», то «Мастер Гу», то просто «мастер». Здесь считается то, что видно без
# выравнивания и сравнимо МЕЖДУ АРМАМИ на одних главах: держит ли арм канонную форму там,
# где zh-термин стоит в исходнике, и сколько раз этот признак ПЕРЕКЛЮЧАЕТСЯ между соседними
# главами. Переключение и есть дрейф; удержание — консистентность.
print(f"{'арм':6s}{'клеток':>8s}{'канон/исходник':>14s}{'покрытие':>9s}"
f"{'полных':>8s}{'нулей':>8s}{'ты/вы':>12s}")
common = [c for c in ch if all(text(a, c["uid"]).strip() for a in DRIFT_ARMS)]
print(f" (общих глав у всех армов: {len(common)})")
# ⚠ СЧЁТНАЯ МЕТРИКА. Прежняя спрашивала «нашлась ли канонная форма ХОТЯ БЫ РАЗ в главе» и
# была ТРИВИАЛЬНО ВЫПОЛНИМА: текст с одним каноном и двадцатью соперничающими формами получал
# «держится», эхо самого глоссария без строки перевода — тоже. Здесь считается ПОКРЫТИЕ:
# сколько раз zh-термин стоит в исходнике и сколько раз канонная форма стоит в переводе.
# (Восстановлено после инцидента 08.08, D39.113.)
per_arm = {}
for a in DRIFT_ARMS:
src_n = can_n = 0
detail: dict[str, list] = {}
for zh, (_v1, _v2, r1, _r2) in BO.IP.TERMS.items():
seq = []
for c in common:
s = c["source"].count(zh)
if not s:
continue
k = len(re.findall(r1, text(a, c["uid"]), re.I))
src_n += s
can_n += min(k, s)
seq.append((c["no"], min(1.0, k / s)))
if len(seq) > 1:
detail[zh] = seq
per_arm[a] = detail
cov = [x for d in detail.values() for _n, x in d]
full = sum(1 for x in cov if x >= 1.0)
zero = sum(1 for x in cov if x == 0.0)
ty = sum(1 for c in common
if re.search(r"(?<![а-яё])ты(?![а-яё])", text(a, c["uid"]), re.I)
and re.search(r"(?<![а-яё])вы(?![а-яё])", text(a, c["uid"]), re.I))
print(f"{a:6s}{len(cov):>8d}{can_n}/{src_n:<9d}{st.mean(cov):>9.2f}"
f"{full:>8d}{zero:>8d}{ty:>10d}/{len(common)}")
# ⚠ КЛЕТКИ С ОДНИМ УПОМИНАНИЕМ разрешения не имеют: при s=1 покрытие бинарно, то есть ровно
# та метрика, которую приёмка забраковала. Считаются отдельно, а не смешиваются.
print("\nТО ЖЕ БЕЗ КЛЕТОК С ОДНИМ УПОМИНАНИЕМ:")
for a in DRIFT_ARMS:
s2 = c2 = n2 = 0
for zh, (_v1, _v2, r1, _r2) in BO.IP.TERMS.items():
for c in common:
s = c["source"].count(zh)
if s < 2:
continue
s2 += s
c2 += min(len(re.findall(r1, text(a, c["uid"]), re.I)), s)
n2 += 1
print(f" {a:6s} {c2}/{s2} = {c2 / s2 if s2 else 0:.3f} на {n2} клетках")
print("\nПОТЕРМИННО (покрытие: доля упоминаний исходника, отражённых канонной формой):")
for a in DRIFT_ARMS:
print(f" {a}:")
for zh, seq in sorted(per_arm[a].items()):
print(f" {zh:6s} " + " ".join(f"{n}:{o:.2f}" for n, o in seq))
print("\nРОСТ СЛОВАРЯ (новых слов на главу; растущая кривая = арм не помнит решений):")
for a in DRIFT_ARMS:
vocab, new = set(), []
for c in have[a]:
w = set(re.findall(r"[а-яё]{4,}", text(a, c["uid"]).lower()))
new.append(len(w - vocab))
vocab |= w
half = len(new) // 2
print(f" {a:5s} первая половина {st.mean(new[:half]):.0f}/гл · "
f"вторая {st.mean(new[half:]):.0f}/гл · всего уникальных {len(vocab)}")
if __name__ == "__main__":
a = sys.argv[1:] or ["--plan"]
{"--plan": cmd_plan, "--arms": cmd_arms, "--score": cmd_score}.get(
a[0], lambda: print(__doc__))()