#!/usr/bin/env python3 """exp15 — deterministic structural profiler ($0), §B1.3 StructureProfile mirror. Characterizes ANY text slice so the report can print a per-slice structural profile BEFORE the paid run and verify the slice actually exercises the lever it is meant to (anti-null, polygon-sync lesson). This is an EVAL-side profiler (Python); the production profiler is Go (chunker StructureProfile, backend zone) — at divergence trust Go. Pure function of input bytes; no model calls. Metrics (per §A.3 step 0 / §B1.3): - lines, nonblank_lines, blank_run_density (fraction of blank-line runs >=2 among all line gaps) - median_chars_per_nonblank_line (line-per-sentence proxy: guzhenren 一句一段 => short lines) - dialogue_opener_share (lines opening with 「『“«»"— - etc.) - terminator_density (。!?!?… per 100 chars — sentence-boundary signal) - cjk_share (Han+Kana / all non-space chars) - ornament_lines (lines that are runs of separator glyphs *◇◆■○※*#—═… or 分割线) - separator_inventory (which ornament glyphs actually occur as separator lines) - section_markers (第…节 / 第…卷 / 第…話 / Chapter … counts) Verdict heuristic per lever: flat_line_per_sentence = median_chars_per_nonblank_line small AND dialogue lines line-per-utterance scene_marked = ornament_lines > 0 (real separators present) Run: eval/.venv/bin/python eval/exp15/structural_profile.py [ ...] eval/.venv/bin/python eval/exp15/structural_profile.py --json # machine-readable """ from __future__ import annotations import json import statistics import sys import unicodedata from pathlib import Path DIALOGUE_OPENERS = tuple("「『“«»\"”—") # plus ascii "- " handled separately ORNAMENT_CHARS = set("*◇◆■○※*#—═―─•·∗☆★==~〜─━__$§") # separator glyphs (per §B1.3 + common webnovel) TERMINATORS = set("。!?!?…‼⁇.") def is_cjk(ch: str) -> bool: o = ord(ch) return (0x4E00 <= o <= 0x9FFF or 0x3040 <= o <= 0x30FF or 0x3400 <= o <= 0x4DBF or 0xF900 <= o <= 0xFAFF or 0x20000 <= o <= 0x2A6DF) def is_ornament_line(s: str) -> bool: t = s.strip() if "分割线" in t or "分隔线" in t: return True if len(t) < 2: return False core = [c for c in t if not c.isspace()] if len(core) < 2: return False return all(c in ORNAMENT_CHARS for c in core) def profile(text: str) -> dict: lines = text.split("\n") nonblank = [ln for ln in lines if ln.strip()] # blank-run density: count maximal runs of >=2 consecutive blank lines blank_runs2 = 0 run = 0 for ln in lines: if ln.strip(): if run >= 2: blank_runs2 += 1 run = 0 else: run += 1 if run >= 2: blank_runs2 += 1 total_gaps = max(1, len(nonblank)) lens = [len(ln.strip()) for ln in nonblank] dlg = 0 for ln in nonblank: s = ln.lstrip() if s.startswith(DIALOGUE_OPENERS) or s.startswith("- ") or s.startswith("― "): dlg += 1 ornament = [ln.strip() for ln in nonblank if is_ornament_line(ln)] orn_inv = sorted({c for o in ornament for c in o if not c.isspace()}) allchars = [c for c in text if not c.isspace()] cjk = sum(1 for c in allchars if is_cjk(c)) terms = sum(1 for c in text if c in TERMINATORS) import re sec_jie = len(re.findall(r"第[0-9一二三四五六七八九十百千零两]{1,10}[节節]", text)) sec_juan = len(re.findall(r"第[0-9一二三四五六七八九十百千零两]{1,10}[卷]", text)) sec_hua = len(re.findall(r"第[0-90-9一二三四五六七八九十百]+[話话]", text)) sec_ch = len(re.findall(r"(?mi)^\s*chapter\s+[a-z0-9]", text)) median_len = statistics.median(lens) if lens else 0 return { "chars": len(text), "lines": len(lines), "nonblank_lines": len(nonblank), "blank_run2_density": round(blank_runs2 / total_gaps, 4), "median_chars_per_nonblank_line": median_len, "mean_chars_per_nonblank_line": round(statistics.mean(lens), 1) if lens else 0, "dialogue_opener_share": round(dlg / max(1, len(nonblank)), 4), "terminator_per_100char": round(100 * terms / max(1, len(allchars)), 3), "cjk_share": round(cjk / max(1, len(allchars)), 4), "ornament_lines": len(ornament), "separator_inventory": orn_inv, "section_markers": {"jie": sec_jie, "juan": sec_juan, "hua": sec_hua, "chapter": sec_ch}, "verdict": { "flat_line_per_sentence": bool(median_len and median_len <= 40 and cjk > 0), "scene_marked": len(ornament) > 0, }, } def main(): args = [a for a in sys.argv[1:] if a != "--json"] as_json = "--json" in sys.argv out = {} for path in args: p = Path(path) if not p.exists(): print(f"[MISSING] {path}", file=sys.stderr) continue raw = p.read_bytes() try: text = raw.decode("utf-8") except UnicodeDecodeError: text = raw.decode("gb18030", errors="replace") pr = profile(text) out[p.name] = pr if not as_json: v = pr["verdict"] print(f"\n### {p.name} ({pr['chars']:,} chars, {pr['nonblank_lines']:,} nonblank lines)") print(f" median chars/line = {pr['median_chars_per_nonblank_line']} (mean {pr['mean_chars_per_nonblank_line']}) " f"cjk={pr['cjk_share']} term/100c={pr['terminator_per_100char']}") print(f" dialogue-opener share = {pr['dialogue_opener_share']} blank-run>=2 density = {pr['blank_run2_density']}") print(f" ornament/separator lines = {pr['ornament_lines']} inventory={pr['separator_inventory']}") print(f" section markers = {pr['section_markers']}") print(f" VERDICT: flat_line_per_sentence={v['flat_line_per_sentence']} scene_marked={v['scene_marked']}") if as_json: print(json.dumps(out, ensure_ascii=False, indent=2)) if __name__ == "__main__": main()