#!/usr/bin/env python3 """exp15 — chunkers for Q1: a faithful zh source-sentence splitter + the §B1.5 LOGICAL DP chunker. Q1 contrast (design §D2): A0 (greedy-1500, from the real backend) vs a LOGICAL-boundary arm sized to A0's ACTUAL est_out distribution (±10% mean). The ONLY variable is cut ALIGNMENT (greedy is blind to scene structure; logical aligns cuts to scene-shift boundaries and avoids mid-scene/dialogue cuts). Size is held equal so Q1 does not confound boundary class with chunk size (that is Q2's domain). Ports (backend/internal/pipeline/chunker.go): splitSourceSentences / isOpenQuote / isCloseQuote / isSourceTerminator / isCJKTerminator / isClosingBracket / isASCIISpace (chunker.go:196-357) — quote-depth-aware, lossless tiling. Token math: est_tokens = cjk + other//3 (EstimateTokens units — the greedy 1500 budget space) est_out = 1.1978*cjk + 0.3852*other (fertility_calib.json — glm-4.6 ru output tokens; §B1.2) The logical chunker implements §B1.4 boundary classes (B0 chapter/separator, B1 scene-shift lexicon, B2 TextTiling cohesion valley, B3 paragraph, B4 sentence) and §B1.5 DP packing (Knuth-Plass shortest path over candidate cuts, INTEGER-scaled costs, asymmetric size penalty). It does NOT peek at the ground-truth seam offsets — scene-shifts are DISCOVERED (that is the Q1 test). Self-review by execution: `python chunker.py --selftest` checks tiling losslessness + a size sweep. """ from __future__ import annotations import math import sys import unicodedata from dataclasses import dataclass from pathlib import Path import regex _RE_CJK = regex.compile(r"[\p{Han}\p{Hiragana}\p{Katakana}\p{Hangul}]") TARGET_CHUNK_TOKENS = 1500 # chunker.go:47 const F_CJK, F_OTHER = 1.1978, 0.3852 # fertility_calib.json (glm-4.6 ru out tokens from src char classes) # --- char-class token estimators ------------------------------------------------------------------- def _classes(text: str): cjk = other = 0 for ch in text: if _RE_CJK.match(ch): cjk += 1 elif ch.isspace(): pass else: other += 1 return cjk, other def est_tokens(text: str) -> int: """EstimateTokens units (source side): cjk + other//3, floored at 16 (chunker.go estTokensFrom).""" cjk, other = _classes(text) v = cjk + other // 3 return max(v, 16) def est_out(text: str) -> float: """Predicted glm-4.6 ru OUTPUT tokens for a source slice (fertility model; §B1.2).""" cjk, other = _classes(text) return F_CJK * cjk + F_OTHER * other # --- GREEDY chunker port (chunker.go:82-194 + render.go:285 NormalizeSource) ----------------------- def normalize_source(s: str) -> str: """render.go:285 — BOM strip, CRLF/CR->LF, valid-UTF8 (Python strs already are), NFC, TrimSpace.""" s = s.lstrip("") s = s.replace("\r\n", "\n").replace("\r", "\n") s = unicodedata.normalize("NFC", s) return s.strip() # Go strings.TrimSpace strips Unicode whitespace incl. U+3000 (== Python str.strip()) def _class_counts(s: str): cjk = other = 0 for ch in s: if _RE_CJK.match(ch): cjk += 1 elif ch.isspace(): pass else: other += 1 return cjk, other def _est_tokens_from(cjk, other): return max(cjk + other // 3, 16) # chunker.go estTokensFrom (integer // 3, floor 16) def split_paragraphs(chapter: str): return [p.strip() for p in chapter.split("\n\n") if p.strip()] def _pack_sentences(paragraph: str): segs = split_source_sentences(paragraph) chunks, buf, bc, bo = [], [], 0, 0 def flush(): nonlocal buf, bc, bo if buf: chunks.append("".join(buf)); buf, bc, bo = [], 0, 0 for s in segs: sc, so = _class_counts(s) if buf and _est_tokens_from(bc + sc, bo + so) > TARGET_CHUNK_TOKENS: flush() buf.append(s); bc, bo = bc + sc, bo + so flush() return chunks def greedy_chunks(raw_text: str): """Port of chunker.go SplitChunks for ONE chapter (S2' has no chapter markers). Returns chunk texts.""" chapter = normalize_source(raw_text) paras = split_paragraphs(chapter) chunks, buf, bc, bo = [], [], 0, 0 def flush(): nonlocal buf, bc, bo if buf: text = "\n\n".join(buf).strip() if text: chunks.append(text) buf, bc, bo = [], 0, 0 for p in paras: pc, po = _class_counts(p) if _est_tokens_from(pc, po) > TARGET_CHUNK_TOKENS: flush() for sub in _pack_sentences(p): t = sub.strip() if t: chunks.append(t) continue if buf and _est_tokens_from(bc + pc, bo + po) > TARGET_CHUNK_TOKENS: flush() buf.append(p); bc, bo = bc + pc, bo + po flush() return chunks # --- source sentence splitter (port of chunker.go:196-357) ----------------------------------------- _OPEN_Q = set("「『(【《〈[{“‘") _CLOSE_Q = set("」』)】》〉]}”’") _TERM = set("。!?.!?…") _CJK_TERM = set("。!?") _CLOSE_BRK = set("」』】))》〉]]}}\"'”’") _ASCII_SPACE = set(" \t\n\r\f\v ") def split_source_sentences(paragraph: str): runes = list(paragraph) segs = [] start = i = depth = 0 n = len(runes) while i < n: r = runes[i] if r in _OPEN_Q: depth += 1 i += 1 continue if r in _CLOSE_Q: if depth > 0: depth -= 1 i += 1 continue if r not in _TERM: i += 1 continue j = i cjk = False while j < n and runes[j] in _TERM: if runes[j] in _CJK_TERM: cjk = True j += 1 depth_here = depth e = j while e < n and runes[e] in _CLOSE_BRK: if runes[e] in _CLOSE_Q and depth > 0: depth -= 1 e += 1 if cjk: boundary = depth_here == 0 else: boundary = depth_here == 0 and (e >= n or runes[e] in _ASCII_SPACE) # abbrev guard omitted for zh source (CJK terminators dominate; en not in S2') if not boundary: i = e continue w = e while w < n and runes[w] in _ASCII_SPACE: w += 1 segs.append("".join(runes[start:w])) start = i = w if start < n: segs.append("".join(runes[start:])) return segs # --- units: split the text into atomic sentence-ish spans that TILE it ----------------------------- def units_from_text(text: str): """Atomic units for the DP = source sentences. Preserves whether each unit ENDS a paragraph (\\n\\n) or a line (\\n) for boundary classification. Tiling: ''.join(u.text) == text.""" out = [] # split into paragraph blocks on blank lines, remember the separator that followed each block # (so paragraph-end vs line-end vs sentence-end is recoverable). We tile exactly by re-attaching # the separators to the preceding unit. for seg, sep in _iter_blocks(text): sents = split_source_sentences(seg) for k, s in enumerate(sents): ends_para = (k == len(sents) - 1) and sep == "\n\n" ends_line = (k == len(sents) - 1) and sep == "\n" out.append((s, ends_para, ends_line)) # re-attach the block separator to the last unit so tiling is exact if out and sep: s, ep, el = out[-1] out[-1] = (s + sep, ep, el) return out def _iter_blocks(text: str): """Yield (block_text, separator) where separator is '\\n\\n' | '\\n' | ''. Tiles the input.""" i = 0 n = len(text) buf_start = 0 while i < n: if text[i] == "\n": if i + 1 < n and text[i + 1] == "\n": yield text[buf_start:i], "\n\n" i += 2 buf_start = i continue else: yield text[buf_start:i], "\n" i += 1 buf_start = i continue i += 1 if buf_start < n: yield text[buf_start:], "" # --- boundary classification (§B1.4) --------------------------------------------------------------- # B1 scene-shift lexicon: STRONG time/place/POV shift markers in the first chars of the NEXT unit. # Curated generic zh markers (NOT the S2' seam text — scene-shifts must be DISCOVERED). Kept to strong # temporal/spatial jumps + narrative shift particles; weak mid-scene connectors (顿时/忽然) excluded. _B1_MARKERS = [ # temporal jumps "次日", "翌日", "第二天", "第三天", "隔日", "数日后", "数天后", "几天后", "三天后", "三天三夜", "一个星期", "一周", "几个月", "半年", "一年后", "三年后", "五年后", "多年后", "当天", "这一天", "那一天", "这一日", "翌朝", "清晨", "早晨", "拂晓", "五更", "天亮", "天明", "傍晚", "黄昏", "夜里", "半夜", "深夜", "入夜", "朝阳", "红日", "夕阳", "日落", "日出", "正午", "转眼", "转眼间", "很快", "不久", "不多时", "没过多久", "光阴荏苒", "时光飞逝", # narrative-shift particles / POV or place switch "却说", "再说", "话说", "单说", "且说", "与此同时", "另一边", "另一方", "一方", "此时此刻", "回到", "镜头", "视线", ] def _b1_scene_shift(next_unit_text: str) -> bool: head = next_unit_text.lstrip("  \n\t")[:12] return any(m in head for m in _B1_MARKERS) def _dialogue_boundary_penalty(prev_unit: str, next_unit: str) -> int: """Penalize cutting between a reply and its attribution or inside a dialogue exchange (§A.6): if prev ends inside/with a quote and next continues speech attribution, cutting is bad.""" p = prev_unit.rstrip("  \n\t") nx = next_unit.lstrip("  \n\t") pen = 0 # prev ends with a closing quote AND next starts with attribution-ish (说道/道/问/答 ...) -> keep together if p and p[-1] in _CLOSE_Q and nx[:2] in ("说道", "说", "道", "问道", "答道", "喝道", "笑道"): pen += 1 # next starts a quote while prev did not close one cleanly (mid-exchange) — mild if nx[:1] in _OPEN_Q and p and p[-1] in _OPEN_Q: pen += 1 return pen # --- TextTiling cohesion valley (B2) --------------------------------------------------------------- def _char_bigrams(text: str): t = [c for c in text if not c.isspace()] return [t[k] + t[k + 1] for k in range(len(t) - 1)] def _cosine(a: dict, b: dict) -> float: if not a or not b: return 0.0 dot = sum(v * b.get(k, 0) for k, v in a.items()) na = math.sqrt(sum(v * v for v in a.values())) nb = math.sqrt(sum(v * v for v in b.values())) return dot / (na * nb) if na and nb else 0.0 def _bag(units, lo, hi): d = {} for u in units[lo:hi]: for bg in _char_bigrams(u[0]): d[bg] = d.get(bg, 0) + 1 return d def texttiling_depths(units, w=4): """Depth score of the lexical-cohesion valley at each inter-unit gap (higher = stronger boundary). Sliding window of w units either side; cosine of char-bigram bags; depth = local valley prominence.""" n = len(units) gaps = n - 1 sims = [0.0] * gaps for g in range(gaps): left = _bag(units, max(0, g + 1 - w), g + 1) right = _bag(units, g + 1, min(n, g + 1 + w)) sims[g] = _cosine(left, right) # depth score: for each gap, (peak_left - sim) + (peak_right - sim) depths = [0.0] * gaps for g in range(gaps): lpk = sims[g] k = g while k > 0 and sims[k - 1] >= sims[k]: k -= 1 lpk = max(lpk, sims[k]) rpk = sims[g] k = g while k < gaps - 1 and sims[k + 1] >= sims[k]: k += 1 rpk = max(rpk, sims[k]) depths[g] = (lpk - sims[g]) + (rpk - sims[g]) return depths # --- §B1.5 DP logical chunker ---------------------------------------------------------------------- @dataclass class ChunkPlan: chunks: list # list of (text, est_out, right_boundary_class) cut_offsets: list # rune offsets in the ORIGINAL text where a cut falls (chunk starts, excl 0) # boundary-class penalty (§B1.5): B0=0 < B1 < B2 < B3 < B4 (prefer cutting at strong boundaries) _CLASS_PEN = {"B0": 0, "B1": 1, "B2": 3, "B3": 6, "B4": 10} def logical_chunks(text: str, target_out: float, hard_max_out: float, min_out: float = None, w_size=3, w_bound=60, w_dialog=200, b2_z=0.8, corridor_lo=0.62, corridor_hi=1.12) -> ChunkPlan: """§B1.5 DP: minimize Σ [w_size·size_pen + w_bound·class_pen(right boundary) + w_dialog·dialogue]. Cuts only at unit (sentence) boundaries — never mid-sentence (B5 forbidden). Costs INTEGER-scaled. target_out/hard_max_out = A0's actual est_out mean/max (size-match).""" units = units_from_text(text) n = len(units) if min_out is None: min_out = 0.45 * target_out # per-unit est_out and cumulative rune offsets out_of = [est_out(u[0]) for u in units] cum_out = [0.0] * (n + 1) cum_rune = [0] * (n + 1) for k in range(n): cum_out[k + 1] = cum_out[k] + out_of[k] cum_rune[k + 1] = cum_rune[k] + len(units[k][0]) # boundary class at each inter-unit gap g (cut AFTER unit g, i.e. between unit g and g+1) depths = texttiling_depths(units) if depths: mu = sum(depths) / len(depths) sd = math.sqrt(sum((d - mu) ** 2 for d in depths) / len(depths)) or 1e-9 gap_class = [] gap_dialog = [] for g in range(n - 1): cls = "B4" # default: sentence boundary if units[g][2] or units[g][1]: cls = "B3" # ends a line/paragraph if depths and (depths[g] - mu) / sd >= b2_z: cls = "B2" # TextTiling cohesion valley if _b1_scene_shift(units[g + 1][0]): cls = "B1" # scene-shift lexicon in next unit gap_class.append(cls) gap_dialog.append(_dialogue_boundary_penalty(units[g][0], units[g + 1][0])) lo_band, hi_band = corridor_lo * target_out, corridor_hi * target_out def size_pen(a, b): """Corridor size penalty (§B1.5 intent: size defines a BUDGET CORRIDOR; boundary class decides WITHIN it). Flat (0) inside [lo_band, hi_band]; steep outside so chunks stay near target size.""" o = cum_out[b] - cum_out[a] if o > hard_max_out: return 100000 + int(40 * (o - hard_max_out)) # hard ceiling — never exceed if o > hi_band: return int(30 * (o - hi_band)) # over corridor if o < lo_band: return int(30 * (lo_band - o)) # under corridor return 0 # inside corridor: size is free, boundary decides INF = float("inf") # dp[b] = min cost to segment units[0:b]; back[b] = previous cut index a dp = [INF] * (n + 1) back = [0] * (n + 1) dp[0] = 0 for b in range(1, n + 1): # try all chunk starts a < b (bounded to a window where size is plausible) a = b - 1 while a >= 0: o = cum_out[b] - cum_out[a] if o > hard_max_out * 1.6 and a < b - 1: break # window: chunks far over hardMax are never optimal cost = dp[a] + w_size * size_pen(a, b) if b < n: # boundary penalty on the RIGHT cut (gap b-1) cost += w_bound * _CLASS_PEN[gap_class[b - 1]] + w_dialog * gap_dialog[b - 1] if cost < dp[b]: dp[b] = cost back[b] = a a -= 1 # reconstruct cuts = [] b = n while b > 0: a = back[b] cuts.append((a, b)) b = a cuts.reverse() chunks = [] cut_offsets = [] for (a, b) in cuts: txt = "".join(units[k][0] for k in range(a, b)) rcls = gap_class[b - 1] if b < n else "B0" chunks.append((txt, est_out(txt), rcls)) if a > 0: cut_offsets.append(cum_rune[a]) return ChunkPlan(chunks=chunks, cut_offsets=cut_offsets) if __name__ == "__main__": if "--selftest" in sys.argv: text = Path("/home/ubuntu/books/gu-zhenren/exp15/S2prime.txt").read_text(encoding="utf-8") units = units_from_text(text) tiled = "".join(u[0] for u in units) print("units:", len(units), "tiling lossless:", tiled == text) assert tiled == text, "UNIT TILING NOT LOSSLESS" total_out = est_out(text) print(f"S2' total est_out={total_out:.0f} est_tokens={est_tokens(text)}") # size sweep: how many logical chunks at a few targets for tgt in (1500, 1797, 2200): plan = logical_chunks(text, target_out=tgt, hard_max_out=tgt * 1.25) outs = [c[1] for c in plan.chunks] classes = [c[2] for c in plan.chunks[:-1]] # right-boundary classes (excl last) from collections import Counter print(f" target_out={tgt}: {len(plan.chunks)} chunks " f"mean_out={sum(outs)/len(outs):.0f} min={min(outs):.0f} max={max(outs):.0f} " f"cut-classes={dict(Counter(classes))}") print("chunker selftest OK")