245 lines
13 KiB
Python
245 lines
13 KiB
Python
#!/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())
|