143 lines
6.1 KiB
Python
143 lines
6.1 KiB
Python
#!/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 <file> [<file> ...]
|
||
eval/.venv/bin/python eval/exp15/structural_profile.py --json <file> # 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()
|