#!/usr/bin/env python3 """Полигон, пакет-8 (замер 1): СОВМЕСТНЫЙ recall двух каналов эмиссии против подписанного банка. Дыра, которую замер закрывает (признана D39.48): вся recall-программа пакета-7 мерила ОДИН канал — офлайн-майнер. Живьём кандидатов в карту подписи кладут ДВА: майнер (WHICH, поверхности без dst) и банкнота черновика (WHAT, поверхность + предложенный перевод). Цена расширения эмиссии зависит от их ОБЪЕДИНЕНИЯ, а не от майнера в одиночку. Дисциплина (иначе цифра врёт в свою пользу): - оба канала меряются на ОДНОМ срезе — том, на котором реально гонялась банкнота; - знаменатель — только термы сида, физически встречающиеся в срезе; - майнер гоняется с ПУСТЫМ сидом (`miner_emit.go` исключает засеянное по построению); - ключи нормализуются БОЕВЫМ `text.NormalizeSourceKey` через `termharness -mode norm`, а не приблизительной репликой на Python (пакет-7 честно помечал это расхождение — здесь его нет); - ГЛАВНОЕ: банкнота гонялась с НЕПУСТЫМ банком, а промт роли просит «новые, которых НЕТ в приложенном глоссарии». Значит инъектированный терм сида банкнота не могла предложить ПО ПОСТРОЕНИЮ — ровно та же ловушка, из-за которой майнеру нужен пустой сид. Поэтому знаменатель разбивается на цензурированную часть (инъектировался хотя бы раз) и часть со свободным шансом, и recall банкноты считается на второй. Пре-регистрировано в §0.2 отчёта ДО прогона. $0: ни одного сетевого вызова, ни одной модели. Читает только КОПИИ баз в скретчпаде. """ from __future__ import annotations import argparse import json import re import sqlite3 import subprocess import sys from pathlib import Path import yaml BANK_SEP = "⟦TM-BANK-v1⟧" BANK_FRAGMENT = "TM-BANK" # Тот же терпимый разделитель полей, что в banknote.go:64 (таб | ≥2 пробела | пайп с окружением). FIELD_SPLIT = re.compile(r"\t| {2,}|\s*\|\s*") TYPE_OK = {"name", "place", "title", "term", "nickname"} def has_han(s: str) -> bool: """Точный диапазон banknote.go:127-134 (U+3400–U+9FFF), НЕ более широкий unicode.Han.""" return any(0x3400 <= ord(c) <= 0x9FFF for c in s) def split_banknote(output: str) -> tuple[str, str, bool]: """Реплика splitBanknote (banknote.go:86-105) вместе с распознаванием СЛОМАННОГО разделителя.""" idx = output.find(BANK_SEP) if idx < 0: i = output.find(BANK_FRAGMENT) if i < 0: return output.rstrip(), "", False nl = output.rfind("\n", 0, i) return output[: max(nl, 0)].rstrip(), "", True return output[:idx].rstrip(), output[idx + len(BANK_SEP) :].strip("\n"), False def parse_banknote(block: str) -> tuple[list[dict], int]: """Реплика parseBanknote (banknote.go:140-187) при truncatedGeneration=false.""" entries, bad = [], 0 for ln in block.split("\n"): if not ln.strip(): continue parts = [p.strip() for p in FIELD_SPLIT.split(ln.strip()) if p.strip()] if len(parts) < 2: bad += 1 continue src, dst = parts[0], parts[1] typ = parts[2].lower() if len(parts) >= 3 else "term" if typ not in TYPE_OK: typ = "term" if not has_han(src): bad += 1 continue entries.append({"src": src, "dst": dst, "type": typ}) return entries, bad def norm_keys(harness: Path, strings: list[str]) -> dict[str, str]: """Боевая нормализация ключей одним вызовом Go-харнесса. Пустые/дублирующиеся строки безопасны.""" uniq = sorted({s for s in strings if s.strip()}) if not uniq: return {} p = subprocess.run([str(harness), "-mode", "norm"], input="\n".join(uniq), capture_output=True, text=True, check=True) out = {} for line in p.stdout.split("\n"): if not line: continue src, _, key = line.partition("\t") out[src] = key missing = [s for s in uniq if s not in out] if missing: raise SystemExit(f"нормализация не покрыла {len(missing)} строк, напр. {missing[:3]}") return out def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--seed", required=True, type=Path) ap.add_argument("--chunks", required=True, type=Path, help="JSONL среза (тот же, что у майнера)") ap.add_argument("--mined-tsv", required=True, type=Path) ap.add_argument("--db", action="append", required=True, type=Path, help="КОПИЯ базы прогона с банкнотой; можно несколько") ap.add_argument("--primary-db", type=Path, help="какая из баз — основной прогон (для цензуры)") ap.add_argument("--harness", required=True, type=Path, help="termharness (для -mode norm)") ap.add_argument("--out", type=Path) a = ap.parse_args() text = "\n".join(json.loads(l)["source"] for l in a.chunks.open(encoding="utf-8") if l.strip()) # --- канал 1: майнер ------------------------------------------------------------------------- mined_primary, mined_alias = [], [] for i, line in enumerate(a.mined_tsv.open(encoding="utf-8")): if i == 0: continue f = line.rstrip("\n").split("\t") if not f or not f[0]: continue mined_primary.append(f[0]) if len(f) > 4 and f[4]: mined_alias.extend(x for x in f[4].split("|") if x) # --- канал 2: банкнота ----------------------------------------------------------------------- bank_rows, per_db = [], {} for db in a.db: con = sqlite3.connect(f"file:{db}?mode=ro", uri=True) n_blocks, n_bad = 0, 0 rows = [] q = ("select response_text, finish_reason from checkpoints " "where role='translator' and response_text like '%TM-BANK%'") for resp, finish in con.execute(q): _, block, malformed = split_banknote(resp or "") if malformed or not block: n_bad += 1 continue # finish=stop-only gate (banknote.go:261): прод принимает кандидатов ТОЛЬКО из полной # генерации. Повторяем, иначе замер учтёт то, чего движок бы не взял. if finish != "stop": continue ents, bad = parse_banknote(block) n_blocks += 1 n_bad += bad rows.extend(ents) per_db[db.name] = {"blocks": n_blocks, "lines": len(rows), "bad": n_bad} bank_rows.extend(rows) con.close() # --- цензура: что банкнота НЕ МОГЛА предложить по построению --------------------------------- injected = set() if a.primary_db: con = sqlite3.connect(f"file:{a.primary_db}?mode=ro", uri=True) for (blob,) in con.execute("select injected_ids from retrieval_state where injected_ids<>''"): for ident in json.loads(blob): injected.add(ident.split("\x1f")[0]) con.close() # --- сид ------------------------------------------------------------------------------------- doc = yaml.safe_load(a.seed.read_text(encoding="utf-8")) or {} terms = doc.get("terms") or [] all_strings = list(injected) + mined_primary + mined_alias + [r["src"] for r in bank_rows] for t in terms: all_strings.append(t.get("src") or "") all_strings.extend(al.get("alias", "") for al in (t.get("aliases") or [])) nk = norm_keys(a.harness, all_strings) mined_keys = {nk[s] for s in mined_primary if s in nk} mined_any = mined_keys | {nk[s] for s in mined_alias if s in nk} bank_keys = {nk[r["src"]] for r in bank_rows if r["src"] in nk} injected_keys = {nk[s] for s in injected if s in nk} rows = [] for t in terms: src = t.get("src") or "" surfaces = [src] + [al.get("alias", "") for al in (t.get("aliases") or [])] present = [s for s in surfaces if s and s in text] if not present: continue keys = {nk[s] for s in surfaces if s in nk} rows.append({ "src": src, "type": t.get("type", "") or "term", "dst": t.get("dst", ""), "occurrences": sum(text.count(s) for s in present), "miner": bool(keys & mined_any), "miner_primary": bool(keys & mined_keys), "bank": bool(keys & bank_keys), "censored": bool(keys & injected_keys), }) n = len(rows) free = [r for r in rows if not r["censored"]] def rate(sel, field): return (sum(r[field] for r in sel) / len(sel)) if sel else float("nan") print(f"срез: {a.chunks} термов сида в срезе: {n}") print(f"майнер (терм): {sum(r['miner_primary'] for r in rows):3d} recall={rate(rows,'miner_primary'):.3f}") print(f"майнер (терм∪алиас): {sum(r['miner'] for r in rows):3d} recall={rate(rows,'miner'):.3f}") print(f"банкнота (сырая): {sum(r['bank'] for r in rows):3d} recall={rate(rows,'bank'):.3f} ← НИЖНЯЯ ГРАНИЦА (цензура)") union = sum(r["miner"] or r["bank"] for r in rows) print(f"объединение: {union:3d} recall={union / n:.3f}" if n else "—") print() print(f"цензура: инъектировано хотя бы раз — {sum(r['censored'] for r in rows)}/{n}; " f"со свободным шансом — {len(free)}") if free: print(f" банкнота на свободных: {sum(r['bank'] for r in free)}/{len(free)} = {rate(free,'bank'):.3f}") print(f" майнер на свободных: {sum(r['miner'] for r in free)}/{len(free)} = {rate(free,'miner'):.3f}") print("\nпо классам (знаменатель / майнер / банкнота / объединение):") bytype: dict[str, list[int]] = {} for r in rows: b = bytype.setdefault(r["type"], [0, 0, 0, 0]) b[0] += 1 b[1] += int(r["miner"]) b[2] += int(r["bank"]) b[3] += int(r["miner"] or r["bank"]) for k in sorted(bytype): c, m, bk, u = bytype[k] print(f" {k:9s} {c:3d} {m:3d}={m/c:.3f} {bk:3d}={bk/c:.3f} {u:3d}={u/c:.3f}") # --- множества каналов (вопрос, цензурой НЕ затронутый) -------------------------------------- print(f"\nэмиссия на срезе: майнер {len(mined_keys)} поверхностей · банкнота {len(bank_keys)}") print(f" пересечение: {len(mined_keys & bank_keys)}") print(f" только майнер: {len(mined_keys - bank_keys)}") print(f" только банкнота: {len(bank_keys - mined_keys)}") print(f" объединение: {len(mined_keys | bank_keys)}") print("\nсырьё банкноты по базам:", json.dumps(per_db, ensure_ascii=False)) print("\nНЕ найдено НИ ОДНИМ каналом (топ-20 по частоте):") for r in sorted((r for r in rows if not (r["miner"] or r["bank"])), key=lambda r: -r["occurrences"])[:20]: mark = "цензура" if r["censored"] else "свободный" print(f" {r['src']}\t{r['type']}\t{r['occurrences']}×\t{r['dst']}\t[{mark}]") if a.out: a.out.write_text(json.dumps({ "rows": rows, "per_db": per_db, "mined_keys": sorted(mined_keys), "bank_keys": sorted(bank_keys), "bank_rows": bank_rows, "injected": sorted(injected_keys), }, ensure_ascii=False, indent=1), encoding="utf-8") return 0 if __name__ == "__main__": sys.exit(main())