395 lines
15 KiB
Go
395 lines
15 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"strings"
|
||
"unicode"
|
||
)
|
||
|
||
// chunker.go: the шаг-3a SOURCE segmenter. It turns the per-chapter normalized
|
||
// text produced by ingest.go into an ordered list of (chapter, chunk) units for
|
||
// the runner loop. It replaced the Веха-2 placeholder (chapter split on \f +
|
||
// blank-line paragraph packing to a CHAR budget) with real segmentation: pack
|
||
// whole paragraphs to a ~1–2k-TOKEN budget, and when a single paragraph exceeds
|
||
// the budget descend to SENTENCE boundaries — never splitting a sentence.
|
||
//
|
||
// Contract kept intact for the loop (bookrun.go/chunkrun.go depend on it): SplitChunks emits
|
||
// Chunk{Chapter,ChunkIdx,Text}; ChunkIdx resets to 0 per chapter; the function is
|
||
// a PURE, deterministic function of its input (no time/rand, no map iteration).
|
||
// Its behavior is versioned by chunkerVersion (render.go), folded into the job
|
||
// snapshot — so changing the segmentation is a loud --resnapshot re-translation,
|
||
// never a silent cache miss (§3.4/R6/D5.2). The chunk target is a CONST covered by
|
||
// that version (Р2 = code); were it ever made config-tunable it MUST be folded into
|
||
// the top-level snapshot like coverageSnap (§7d), not left to the version string.
|
||
//
|
||
// IMPORTANT: this sentence splitter is a SEPARATE, independent piece of logic from
|
||
// coverage.go's splitSentences. That one is a bit-for-bit port of the Python oracle
|
||
// (refusal_bench.py) over the RUSSIAN OUTPUT and is Python-locked (D12 Q1). THIS one
|
||
// segments the SOURCE (zh/ja/en), is versioned by chunkerVersion (Р2 = code), and is
|
||
// free to be more faithful (closing-bracket absorption, an abbreviation guard). Do
|
||
// not couple them.
|
||
|
||
// Chunk is one ordered unit of translation work.
|
||
type Chunk struct {
|
||
Chapter int // 1-based
|
||
ChunkIdx int // 0-based within its chapter
|
||
Text string
|
||
}
|
||
|
||
// targetChunkTokens is the packing budget: paragraphs (or, inside an oversize
|
||
// paragraph, sentences) are greedily packed until the WHOLE chunk's estimated
|
||
// tokens (EstimateTokens' formula, applied once — see appendChapterChunks) would
|
||
// exceed this. ~1500 sits in the plan's "1–2k токенов по границам абзацев" corridor
|
||
// (02-mvp Фаза 1); a chunk of ordinary prose lands well above the coverage gate's
|
||
// 500-char floor, so the excision gate is not silently disabled (§7b). A chunk can
|
||
// still fall under the floor only when a whole chapter (or its tail) is genuinely
|
||
// that short — a documented boundary. A const, not config: the size is a code
|
||
// segmentation rule (Р2), covered by chunkerVersion.
|
||
const targetChunkTokens = 1500
|
||
|
||
// SplitChunks segments the ordered, per-chapter NORMALIZED text of a Document
|
||
// (ingest.go already applied NormalizeSource and split chapters) into an ordered
|
||
// chunk list. Chapters that yield no text (blank/whitespace-only, e.g. an epub
|
||
// cover/nav page) do NOT consume a chapter number, so numbering stays dense over
|
||
// real content (Фаза-0 backward compat). Fully deterministic.
|
||
func SplitChunks(chapters []string) []Chunk {
|
||
var out []Chunk
|
||
chapterNo := 0
|
||
for _, chapText := range chapters {
|
||
paras := splitParagraphs(chapText)
|
||
if len(paras) == 0 {
|
||
continue // an empty chapter does not consume a chapter number
|
||
}
|
||
chapterNo++
|
||
out = appendChapterChunks(out, chapterNo, paras)
|
||
}
|
||
return out
|
||
}
|
||
|
||
// appendChapterChunks packs one chapter's paragraphs into chunks and appends them.
|
||
// Rule: pack whole paragraphs while the running token estimate stays within
|
||
// targetChunkTokens (prefer paragraph boundaries); a paragraph that alone exceeds
|
||
// the budget is flushed on its own and then SENTENCE-packed (never splitting a
|
||
// sentence; a single sentence over budget is its own chunk).
|
||
//
|
||
// The running budget is tracked as ADDITIVE char-class counts (CJK vs other),
|
||
// applying EstimateTokens' formula (and its 16-token floor) ONCE to the whole
|
||
// chunk — NOT by summing per-unit EstimateTokens. Summing per-unit floors every
|
||
// tiny paragraph up to 16 tokens, so a dialogue chapter of many short lines would
|
||
// flush a chunk at ~⅓ its true token content, needlessly dropping real content
|
||
// under the coverage gate's 500-char floor (self-review finding). Counting classes
|
||
// and flooring once reproduces EstimateTokens(joined text) exactly (the "\n\n"
|
||
// separators are whitespace, which EstimateTokens ignores).
|
||
func appendChapterChunks(out []Chunk, chapterNo int, paras []string) []Chunk {
|
||
chunkIdx := 0
|
||
var buf []string // paragraphs accumulated for the current chunk
|
||
var bufCJK, bufOther int
|
||
flush := func() {
|
||
if len(buf) == 0 {
|
||
return
|
||
}
|
||
if text := strings.TrimSpace(strings.Join(buf, "\n\n")); text != "" {
|
||
out = append(out, Chunk{Chapter: chapterNo, ChunkIdx: chunkIdx, Text: text})
|
||
chunkIdx++
|
||
}
|
||
buf = buf[:0]
|
||
bufCJK, bufOther = 0, 0
|
||
}
|
||
for _, p := range paras {
|
||
pCJK, pOther := tokenClassCounts(p)
|
||
if estTokensFrom(pCJK, pOther) > targetChunkTokens {
|
||
// Oversize paragraph: close any open chunk at this paragraph boundary,
|
||
// then split the paragraph at sentence boundaries into its own chunks.
|
||
flush()
|
||
for _, sub := range packSentences(p) {
|
||
if text := strings.TrimSpace(sub); text != "" {
|
||
out = append(out, Chunk{Chapter: chapterNo, ChunkIdx: chunkIdx, Text: text})
|
||
chunkIdx++
|
||
}
|
||
}
|
||
continue
|
||
}
|
||
// Normal paragraph: start a new chunk if adding it would exceed the budget
|
||
// and the current chunk is non-empty (never split a paragraph here).
|
||
if len(buf) > 0 && estTokensFrom(bufCJK+pCJK, bufOther+pOther) > targetChunkTokens {
|
||
flush()
|
||
}
|
||
buf = append(buf, p)
|
||
bufCJK, bufOther = bufCJK+pCJK, bufOther+pOther
|
||
}
|
||
flush()
|
||
return out
|
||
}
|
||
|
||
// packSentences splits one oversize paragraph into sentence segments (lossless
|
||
// tiling — concatenation reproduces the paragraph) and packs consecutive segments
|
||
// into ≤ targetChunkTokens sub-chunks, never splitting a sentence. A single
|
||
// sentence larger than the budget becomes its own (oversize) sub-chunk — the only
|
||
// way to honor "never split a sentence". Returns the sub-chunk texts in order. Uses
|
||
// the same additive char-class budget as appendChapterChunks (floor applied once).
|
||
func packSentences(paragraph string) []string {
|
||
segs := splitSourceSentences(paragraph)
|
||
var chunks []string
|
||
var buf strings.Builder
|
||
var bufCJK, bufOther int
|
||
flush := func() {
|
||
if buf.Len() == 0 {
|
||
return
|
||
}
|
||
chunks = append(chunks, buf.String())
|
||
buf.Reset()
|
||
bufCJK, bufOther = 0, 0
|
||
}
|
||
for _, s := range segs {
|
||
sCJK, sOther := tokenClassCounts(s)
|
||
if buf.Len() > 0 && estTokensFrom(bufCJK+sCJK, bufOther+sOther) > targetChunkTokens {
|
||
flush()
|
||
}
|
||
buf.WriteString(s)
|
||
bufCJK, bufOther = bufCJK+sCJK, bufOther+sOther
|
||
}
|
||
flush()
|
||
return chunks
|
||
}
|
||
|
||
// tokenClassCounts counts the two token-estimate character classes of EstimateTokens
|
||
// (render.go): CJK ideographs/kana/hangul, and "other" non-space runes (whitespace
|
||
// folds into neighbours). Kept separate from EstimateTokens so the packer can sum
|
||
// counts ADDITIVELY across units and floor once at the chunk level. Mirrors
|
||
// EstimateTokens' classes; a recalibration there that changes packing granularity
|
||
// is itself a chunkerVersion bump (both are behaviour changes covered by the snapshot).
|
||
func tokenClassCounts(s string) (cjk, other int) {
|
||
for _, r := range s {
|
||
switch {
|
||
case unicode.In(r, unicode.Han, unicode.Hiragana, unicode.Katakana, unicode.Hangul):
|
||
cjk++
|
||
case unicode.IsSpace(r):
|
||
default:
|
||
other++
|
||
}
|
||
}
|
||
return cjk, other
|
||
}
|
||
|
||
// estTokensFrom applies EstimateTokens' formula (cjk + other/3, floored at 16) to
|
||
// pre-counted class totals — identical to EstimateTokens over the joined text.
|
||
func estTokensFrom(cjk, other int) int {
|
||
est := cjk + other/3
|
||
if est < 16 {
|
||
est = 16
|
||
}
|
||
return est
|
||
}
|
||
|
||
// splitParagraphs breaks a chapter into paragraphs on blank lines and trims each,
|
||
// dropping empties. Deterministic; whitespace-only paragraphs vanish. (Ingest emits
|
||
// a blank line between block-level epub elements, so paragraphs survive extraction.)
|
||
func splitParagraphs(chapter string) []string {
|
||
var paras []string
|
||
for _, raw := range strings.Split(chapter, "\n\n") {
|
||
if p := strings.TrimSpace(raw); p != "" {
|
||
paras = append(paras, p)
|
||
}
|
||
}
|
||
return paras
|
||
}
|
||
|
||
// --- source sentence segmentation (zh/ja/en) — chunkerVersion-versioned ---------
|
||
|
||
// splitSourceSentences segments one paragraph at sentence boundaries, returning
|
||
// substrings that TILE the input (their concatenation is exactly the paragraph —
|
||
// no character is lost or moved), so a packed sub-chunk is an exact source slice.
|
||
// A boundary is a run of sentence terminators, plus any closing quotes/brackets, at:
|
||
//
|
||
// (1) a CJK terminator (。!?) — zero-width, CJK prose has no inter-sentence space —
|
||
// but ONLY at quote-depth 0. A terminator inside a quote is dialogue-internal:
|
||
// 「止まれ!」と言った。 is ONE sentence ending at the outer 。, not two (the same
|
||
// for zh “…!”)— this is the dialogue case §3.7/D12-Q1 defers for the coverage
|
||
// oracle, but our source splitter is free to get it right;
|
||
// (2) an ASCII terminator (.!?) or ellipsis (…) — only when followed by whitespace
|
||
// or end-of-paragraph (so a decimal/URL dot mid-token does not split, and en
|
||
// dialogue "Stop!" She ran. still splits on the space after the quote).
|
||
//
|
||
// Trailing whitespace after a boundary is attached to the LEFT segment (the next
|
||
// segment starts at the next content char), keeping the tiling exact. An
|
||
// abbreviation guard suppresses a split after a lone "." following a known
|
||
// abbreviation or a single-letter initial (never split "Mr. Smith" / "J. R. R.").
|
||
// When unsure it prefers NOT to split (over-merge is safe — units just pack
|
||
// together; over-split cuts a sentence, the forbidden case). Unbalanced quotes are
|
||
// tolerated (depth floors at 0), erring toward merge.
|
||
func splitSourceSentences(paragraph string) []string {
|
||
runes := []rune(paragraph)
|
||
var segs []string
|
||
start, i, depth := 0, 0, 0
|
||
for i < len(runes) {
|
||
r := runes[i]
|
||
if isOpenQuote(r) {
|
||
depth++
|
||
i++
|
||
continue
|
||
}
|
||
if isCloseQuote(r) {
|
||
if depth > 0 {
|
||
depth--
|
||
}
|
||
i++
|
||
continue
|
||
}
|
||
if !isSourceTerminator(r) {
|
||
i++
|
||
continue
|
||
}
|
||
// Consume a run of terminators (collapse "。。。" / "!?" into one boundary).
|
||
j := i
|
||
cjk := false
|
||
for j < len(runes) && isSourceTerminator(runes[j]) {
|
||
if isCJKTerminator(runes[j]) {
|
||
cjk = true
|
||
}
|
||
j++
|
||
}
|
||
// The terminator's quote-depth is the depth HERE — before absorbing the
|
||
// closing quote that may follow it (「…!」: the ! is at depth 1, the 」 that
|
||
// closes the quote comes after and must not retro-promote it to depth 0).
|
||
depthHere := depth
|
||
// Absorb a run of closing quotes/brackets that belong to the sentence,
|
||
// decrementing quote-depth for the depth-tracked closers among them.
|
||
e := j
|
||
for e < len(runes) && isClosingBracket(runes[e]) {
|
||
if isCloseQuote(runes[e]) && depth > 0 {
|
||
depth--
|
||
}
|
||
e++
|
||
}
|
||
boundary := false
|
||
if cjk {
|
||
boundary = depthHere == 0 // dialogue-internal terminators do not end a sentence
|
||
} else {
|
||
// ASCII terminator / ellipsis: a boundary only outside a tracked quote AND
|
||
// before whitespace or EOS. The quote-depth guard must apply here too, not
|
||
// just to the CJK branch — otherwise 「Stop. Now.」 over-splits mid-dialogue
|
||
// (external-review #4). Straight-quote en ("Stop!" She ran.) is untracked
|
||
// (depth 0), so it still splits on the space as before.
|
||
boundary = depthHere == 0 && (e >= len(runes) || isASCIISpace(runes[e]))
|
||
// Abbreviation guard: a lone "." after an abbreviation/initial is not a end.
|
||
if boundary && j-i == 1 && runes[i] == '.' && isAbbrevBefore(runes[start:i]) {
|
||
boundary = false
|
||
}
|
||
}
|
||
if !boundary {
|
||
i = e
|
||
continue
|
||
}
|
||
// Attach the following whitespace run to the left segment (keeps tiling exact).
|
||
w := e
|
||
for w < len(runes) && isASCIISpace(runes[w]) {
|
||
w++
|
||
}
|
||
segs = append(segs, string(runes[start:w]))
|
||
start, i = w, w
|
||
}
|
||
if start < len(runes) {
|
||
segs = append(segs, string(runes[start:]))
|
||
}
|
||
return segs
|
||
}
|
||
|
||
// isOpenQuote / isCloseQuote are the DEPTH-TRACKED paired quote/bracket delimiters
|
||
// (CJK brackets + curly quotes with distinct open/close glyphs). Straight quotes
|
||
// (" ') are NOT tracked — same glyph opens and closes, so depth is undecidable; en
|
||
// relies on the whitespace rule instead, which is correct for spaced prose.
|
||
func isOpenQuote(r rune) bool {
|
||
switch r {
|
||
case '「', '『', '(', '【', '《', '〈', '[', '{', '“', '‘':
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
func isCloseQuote(r rune) bool {
|
||
switch r {
|
||
case '」', '』', ')', '】', '》', '〉', ']', '}', '”', '’':
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isSourceTerminator is the sentence-terminator class for the SOURCE: CJK 。!?,
|
||
// ASCII .!?, and the ellipsis … (U+2026).
|
||
func isSourceTerminator(r rune) bool {
|
||
switch r {
|
||
case '。', '!', '?', '.', '!', '?', '…':
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isCJKTerminator is the zero-width-splitting subset (fullwidth CJK). The ellipsis
|
||
// is deliberately NOT here: "……" mid-CJK-sentence must not split, but "……。" still
|
||
// splits on the 。 in the same run.
|
||
func isCJKTerminator(r rune) bool {
|
||
switch r {
|
||
case '。', '!', '?':
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isClosingBracket is the closing-quote/bracket class absorbed into the left
|
||
// sentence after its terminator (ja/zh brackets + straight/curly quotes).
|
||
func isClosingBracket(r rune) bool {
|
||
switch r {
|
||
case '」', '』', '】', ')', ')', '》', '〉', ']', ']', '}', '}',
|
||
'"', '\'', '”', '’':
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isASCIISpace is the whitespace used for ASCII-terminator boundary detection and
|
||
// tiling — the ordinary prose separators (space, tab, newline, CR, form feed,
|
||
// vertical tab, and the ideographic space U+3000).
|
||
func isASCIISpace(r rune) bool {
|
||
switch r {
|
||
case ' ', '\t', '\n', '\r', '\f', '\v', ' ':
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
|
||
// sourceAbbrevs are trailing tokens after which a lone "." is treated as an
|
||
// abbreviation, not a sentence end (case-insensitive). A pragmatic set for prose;
|
||
// the acceptance path is ja→ru where CJK terminators dominate, so this only guards
|
||
// the en source. Single-letter initials are handled separately (isAbbrevBefore).
|
||
var sourceAbbrevs = map[string]bool{
|
||
"mr": true, "mrs": true, "ms": true, "dr": true, "prof": true, "st": true,
|
||
"jr": true, "sr": true, "vs": true, "no": true, "vol": true, "ch": true,
|
||
"fig": true, "col": true, "gen": true, "sgt": true, "capt": true, "lt": true,
|
||
"rev": true, "gov": true, "sen": true, "rep": true, "etc": true, "inc": true,
|
||
"ltd": true, "co": true, "mt": true, "ave": true, "rd": true,
|
||
}
|
||
|
||
// isAbbrevBefore reports whether the text immediately before a lone "." ends in a
|
||
// known abbreviation or a single-letter initial (so the "." is not a boundary).
|
||
func isAbbrevBefore(left []rune) bool {
|
||
// Trailing run of ASCII letters (the "word" before the period).
|
||
end := len(left)
|
||
k := end
|
||
for k > 0 && isASCIILetter(left[k-1]) {
|
||
k--
|
||
}
|
||
word := left[k:end]
|
||
if len(word) == 0 {
|
||
return false
|
||
}
|
||
// Single-letter initial ("J.") — only when it stands alone (start or preceded by
|
||
// a space/opening punctuation), never a one-letter word ending a real sentence
|
||
// mid-flow would be rare; over-merge is the safe side.
|
||
if len(word) == 1 {
|
||
return k == 0 || !isASCIILetter(left[k-1])
|
||
}
|
||
return sourceAbbrevs[strings.ToLower(string(word))]
|
||
}
|
||
|
||
func isASCIILetter(r rune) bool {
|
||
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
|
||
}
|