#!/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 # заказ промта: 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"(?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__))()