304 lines
18 KiB
Python
304 lines
18 KiB
Python
#!/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 Counter, 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 # заказ промта: 8–10
|
||
# ⚠ ПОПРАВКА К ФРИЗУ 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__))()
|