#!/usr/bin/env python3 """exp14 — детерминированный структурный KPI (Претензия 1, БЕЗ судьи) + гварды. $0. Метрика Претензии 1: распределение предложений/нарратив-абзац (baseline P0: медиана 1.0, среднее 1.70, доля 1-предл. 0.58). Гварды: CJK-утечка=0, length-ratio, glossary-присутствие, диалог-тире-парность. Аварийный гейт: нельзя «побеждать» только МЕНЬШИМ числом абзацев → KPI парно с atom-coverage-прокси (длина не должна коллапсировать). Использование: exp14_kpi.py [--scope trap|stage2] """ from __future__ import annotations import argparse, json, re, statistics from pathlib import Path import exp14_common as C ARMS = C.DIAG / "arms" MAT = json.load(open(C.DIAG / "material.json")) def paras(t): return [p for p in re.split(r"\n\s*\n", t.strip()) if p.strip()] def sents(p): return [s for s in re.split(r"(?<=[.!?…])\s+", p.strip()) if s.strip()] def is_dlg(p): return p.strip().startswith("—") or p.strip().startswith("–") def han(t): return sum(1 for c in t if "一" <= c <= "鿿" or "㐀" <= c <= "䶿") def kpi_of(text: str) -> dict: ps = paras(text) narr = [p for p in ps if not is_dlg(p)] dlg = [p for p in ps if is_dlg(p)] sp = [len(sents(p)) for p in narr] or [0] return dict( n_para=len(ps), n_narr=len(narr), n_dlg=len(dlg), median_spp=statistics.median(sp), mean_spp=round(statistics.mean(sp), 2), share_1sent=round(sum(1 for x in sp if x == 1) / len(sp), 3), chars=len(text), han=han(text), ) def gloss_presence(text: str, editor_constraint: str) -> float: """Доля dst-форм из инъекции, чьё СТЕММ (первые 4 симв.) встречается в выходе (прокси).""" dsts = re.findall(r"«([^»]+)»", editor_constraint) if not dsts: return 1.0 hit = 0 for d in dsts: stem = d.split()[0][:4] if stem and stem.lower() in text.lower(): hit += 1 return round(hit / len(dsts), 3) def unit_map(scope): if scope == "trap": return {f"{u['chapter']}.{u['chunk_idx']}": u for u in MAT["trap_chunks"]} m = {} for ch, chunks in MAT["stage2"].items(): for u in chunks: m[f"{ch}.{u['chunk_idx']}"] = dict(chapter=int(ch), **u) return m def main(): ap = argparse.ArgumentParser() ap.add_argument("--scope", default="trap") a = ap.parse_args() units = unit_map(a.scope) arms = sorted(d.name for d in ARMS.iterdir() if d.is_dir()) print(f"=== exp14 KPI ({a.scope}, {len(units)} chunks) — Претензия 1 + гварды ===\n") rows = {} for arm in arms: agg = [] han_total, chars_total, chars_p0 = 0, 0, 0 gl = [] for uid, u in units.items(): fp = ARMS / arm / f"{uid}.txt" if not fp.exists(): continue t = fp.read_text(encoding="utf-8") k = kpi_of(t) agg.append(k); han_total += k["han"]; chars_total += k["chars"] chars_p0 += len(u["final_p0"]) gl.append(gloss_presence(t, u.get("editor_constraint", ""))) if not agg: continue allsp_median = statistics.median([k["median_spp"] for k in agg]) mean_spp = round(statistics.mean([k["mean_spp"] for k in agg]), 2) share1 = round(statistics.mean([k["share_1sent"] for k in agg]), 3) lr = round(chars_total / max(1, chars_p0), 3) rows[arm] = dict(n=len(agg), mean_spp=mean_spp, share_1sent=share1, han=han_total, len_ratio_vs_p0=lr, gloss=round(statistics.mean(gl), 3)) hdr = f"{'arm':<12}{'n':>3}{'mean_spp':>10}{'share_1s':>10}{'CJK':>6}{'len/P0':>8}{'gloss':>7}" print(hdr); print("-" * len(hdr)) # baseline first order = [a for a in ["P0", "P1a", "P1b", "P1c", "A-layout", "A-2pass", "A-ref-prev", "A-ref-next", "A-ref-both", "F-base", "F-disc"] if a in rows] order += [a for a in rows if a not in order] for arm in order: r = rows[arm] print(f"{arm:<12}{r['n']:>3}{r['mean_spp']:>10}{r['share_1sent']:>10}" f"{r['han']:>6}{r['len_ratio_vs_p0']:>8}{r['gloss']:>7}") print("\nПретензия-1 сигнал: mean_spp↑ и share_1sent↓ vs P0 = меньше рублености (лучше);") print("гейт: len/P0 не должен коллапсировать (<~0.9 = подозрение на потерю атомов); CJK=0 обяз.") (C.DIAG / f"kpi_{a.scope}.json").write_text(json.dumps(rows, ensure_ascii=False, indent=1), encoding="utf-8") if __name__ == "__main__": main()