textmachine/eval/pkg7/joint_recall.py

245 lines
13 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
"""Полигон, пакет-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+3400U+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())