#!/usr/bin/env python3 """Полигон, пакет-7 (A3): recall боевого WHICH-канала против ПОДПИСАННОГО ВЛАДЕЛЬЦЕМ банка. Вопрос, на который отвечает скрипт: из терминов, которые владелец УЖЕ подписал для этой книги и которые физически встречаются в срезе, сколько боевой майнер предлагает сам? Дисциплина замера (иначе цифра врёт в свою пользу): - знаменатель — только те термины сида, что РЕАЛЬНО встречаются в срезе (иначе меряем не recall, а длину среза); - майнер прогоняется с ПУСТЫМ сидом, иначе он по построению исключает уже засеянное (`miner_emit.go:134` — `seedSurfaces[c.Src]` → continue) и recall выйдет 0 по определению; - засчитывается и попадание в алиас-кластер: движок считает такой кандидат «алиасом существующего» и владелец видит его на стопе. Обе цифры печатаются раздельно. - ключи нормализуются тем же способом, что и в движке (trad→simp, NFKC, lower) — тут повторено ЧАСТИЧНО (NFKC+casefold): полный `text.NormalizeSourceKey` — Go. Расхождение помечается. $0, stdlib + PyYAML. """ from __future__ import annotations import argparse import json import sys import unicodedata from pathlib import Path import yaml def nk(s: str) -> str: return unicodedata.normalize("NFKC", s).casefold().strip() def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("--seed", required=True, type=Path) ap.add_argument("--chunks", required=True, type=Path) ap.add_argument("--mined-tsv", required=True, type=Path) ap.add_argument("--out", type=Path) a = ap.parse_args() doc = yaml.safe_load(a.seed.read_text(encoding="utf-8")) or {} terms = doc.get("terms") or [] text = "\n".join(json.loads(l)["source"] for l in a.chunks.open(encoding="utf-8") if l.strip()) mined_primary, mined_all = set(), set() 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.add(nk(f[0])) mined_all.add(nk(f[0])) if len(f) > 4 and f[4]: mined_all.update(nk(x) for x in f[4].split("|") if x) 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} rows.append({ "src": src, "type": t.get("type", ""), "dst": t.get("dst", ""), "status": t.get("status", ""), "occurrences": sum(text.count(s) for s in present), "hit_primary": bool(keys & mined_primary), "hit_any": bool(keys & mined_all), }) n = len(rows) hp = sum(r["hit_primary"] for r in rows) ha = sum(r["hit_any"] for r in rows) bytype: dict[str, list[int]] = {} for r in rows: b = bytype.setdefault(r["type"] or "—", [0, 0]) b[0] += 1 b[1] += int(r["hit_any"]) print(f"термов сида, встречающихся в срезе: {n}") print(f" предложены майнером как ПЕРВИЧНЫЙ терм: {hp} recall={hp / n:.3f}" if n else " —") print(f" предложены как терм ИЛИ алиас: {ha} recall={ha / n:.3f}" if n else " —") print("по типам (знаменатель / попало):") for k in sorted(bytype): c, h = bytype[k] print(f" {k:8s} {h:3d}/{c:<3d} = {h / c:.3f}") print("\nНЕ предложенные (топ-25 по частоте):") for r in sorted((r for r in rows if not r["hit_any"]), key=lambda r: -r["occurrences"])[:25]: print(f" {r['src']}\t{r['type']}\t{r['occurrences']}×\t{r['dst']}") if a.out: a.out.write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8") return 0 if __name__ == "__main__": raise SystemExit(main())