textmachine/eval/exp15/structural_profile.py

143 lines
6.1 KiB
Python
Raw 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
"""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-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()