textmachine/backend/internal/pipeline/chunker.go

455 lines
19 KiB
Go
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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.

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. WS2 (слой 1) moved the packing budget to OUTPUT (ru) tokens via
// the fertility estimate (est_out = f_cjk·cjk + f_other·other over source char-classes),
// decoupling draft sizing (small chunk, COGS/coverage/alignment) from the EDIT unit
// (coarse chapter-scale unit for cross-chunk cohesion): pack whole paragraphs to
// DraftBudgetOut, and when a single paragraph exceeds it descend to SENTENCE boundaries
// (never splitting a sentence; a lone over-budget sentence is a passthrough
// OversizedSentence chunk). Edit units then group WHOLE draft chunks to EditCeilingOut.
//
// 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 new fields (EstOut,
// OversizedSentence, EditUnitID) are ADDITIVE; the function is a PURE, deterministic function of
// (chapters, SegBudget) (no time/rand, no map iteration). Its behavior is versioned by
// chunkerVersion (render.go); the SegBudget is snapshot-folded via segmentationSnap — so changing
// the segmentation OR the budget/fertility is a loud --resnapshot re-translation, never a silent
// cache miss (§3.4/R6/D5.2/D30.9).
//
// 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
// EstOut is the fertility-estimated OUTPUT (ru) token cost of this chunk (WS2, слой 1):
// est_out = fert.CJK·cjk_src + fert.Other·other_src over the source char-classes. It is the
// unit the draft budget packs against (DraftBudgetOut), decoupling draft sizing from the
// old input-token heuristic (L2-budget-wrong-unit). Additive field; the {Chapter,ChunkIdx,Text}
// contract the loop/ingest/status depend on is untouched.
EstOut float64
// OversizedSentence marks a chunk that is a SINGLE source sentence whose est_out alone exceeds
// DraftBudgetOut (WS2 §2б). It is passthrough (never clause-split — clause splitting is
// boundary machinery, anti-scope), and the flag makes the un-budgetable unit OBSERVABLE
// ("деградация не молчит") rather than silently oversized.
OversizedSentence bool
// EditUnitID is the 0-based book-global id of the coarse EDIT unit this draft chunk belongs to
// (WS2 decoupling: draft = small chunk, edit = large unit). An edit unit is a greedy grouping of
// WHOLE draft chunks up to EditCeilingOut (per chapter), so W2's editor reads a unit's draft as
// the concatenation of its member chunks' W1 outputs — clean reconstruction by construction (no
// straddle; verified: grouping whole chunks reproduces the paragraph-packed count 37 with 0
// straddle on the rerun corpus). Draft chunks never cross an edit-unit boundary.
EditUnitID int
}
// SegBudget is the resolved output-token segmentation budget (WS2 §2в): the draft-chunk and
// edit-unit ceilings in ru-OUTPUT tokens, plus the per-pair fertility coefficients that convert
// source char-classes to an output-token estimate. Resolved from config.Segmentation by the runner
// and snapshot-folded via segmentationSnap, so a budget/fertility edit is a loud --resnapshot
// (D30.9). The classifier (tokenClassCounts) uses the REAL unicode.RangeTable of EstimateTokens
// (Han|Hiragana|Katakana|Hangul) for generality (§0.1) — NOT script-specific ord ranges.
type SegBudget struct {
DraftBudgetOut float64 // draft-chunk ceiling, ru-output tokens (default 1797)
EditCeilingOut float64 // edit-unit ceiling, ru-output tokens (default 3200, gated arm at 8000)
FertCJK float64 // output tokens per CJK source char (default 1.1978)
FertOther float64 // output tokens per non-CJK/non-space source char (default 0.3852)
}
// estOut applies the fertility formula to pre-counted source char-classes — identical to
// est_out over the joined chunk text (whitespace is class-free, as tokenClassCounts drops it).
func (s SegBudget) estOut(cjk, other int) float64 {
return s.FertCJK*float64(cjk) + s.FertOther*float64(other)
}
// 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. Each chunk carries its
// EstOut + OversizedSentence flag + EditUnitID (WS2); the edit-unit id is monotone across
// the book so a W2 unit is uniquely addressable.
func SplitChunks(chapters []string, seg SegBudget) []Chunk {
var out []Chunk
chapterNo := 0
editUnitID := 0
for _, chapText := range chapters {
paras := splitParagraphs(chapText)
if len(paras) == 0 {
continue // an empty chapter does not consume a chapter number
}
chapterNo++
chapterChunks := chapterDraftChunks(chapterNo, paras, seg)
assignEditUnits(chapterChunks, seg, &editUnitID)
out = append(out, chapterChunks...)
}
return out
}
// chapterDraftChunks packs one chapter's paragraphs into DRAFT chunks (the fine tiling). Rule:
// pack whole paragraphs while the running OUTPUT-token estimate stays within DraftBudgetOut
// (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
// OversizedSentence chunk). The running budget is tracked as ADDITIVE char-class counts (CJK vs
// other), applying est_out ONCE to the whole chunk — the "\n\n" separators are whitespace, which
// tokenClassCounts drops, so counting classes and estimating once reproduces est_out(joined text)
// exactly. Each emitted chunk gets its ChunkIdx (per-chapter) + EstOut; EditUnitID is set later.
func chapterDraftChunks(chapterNo int, paras []string, seg SegBudget) []Chunk {
var out []Chunk
chunkIdx := 0
emit := func(text string, oversized bool, cjk, other int) {
if t := strings.TrimSpace(text); t != "" {
out = append(out, Chunk{
Chapter: chapterNo, ChunkIdx: chunkIdx, Text: t,
EstOut: seg.estOut(cjk, other), OversizedSentence: oversized,
})
chunkIdx++
}
}
var buf []string // paragraphs accumulated for the current chunk
var bufCJK, bufOther int
flush := func() {
if len(buf) == 0 {
return
}
emit(strings.Join(buf, "\n\n"), false, bufCJK, bufOther)
buf = buf[:0]
bufCJK, bufOther = 0, 0
}
for _, p := range paras {
pCJK, pOther := tokenClassCounts(p)
if seg.estOut(pCJK, pOther) > seg.DraftBudgetOut {
// 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, seg) {
emit(sub.text, sub.oversizedSentence, sub.cjk, sub.other)
}
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 && seg.estOut(bufCJK+pCJK, bufOther+pOther) > seg.DraftBudgetOut {
flush()
}
buf = append(buf, p)
bufCJK, bufOther = bufCJK+pCJK, bufOther+pOther
}
flush()
return out
}
// assignEditUnits groups a chapter's already-built DRAFT chunks into EDIT units — greedy packing of
// WHOLE draft chunks until the accumulated est_out would exceed EditCeilingOut — and stamps each
// chunk's EditUnitID (monotone across the book via *nextID). Because units are formed from whole
// draft chunks, a unit's source/draft span is exactly the concatenation of its member chunks (no
// straddle) and W2 reconstructs the unit's draft losslessly. A single draft chunk larger than the
// ceiling (an OversizedSentence, or a chapter whose first chunk already exceeds the ceiling) is its
// own unit — grouping never splits a chunk (never-split-below-chunk mirrors never-split-paragraph).
func assignEditUnits(chunks []Chunk, seg SegBudget, nextID *int) {
acc := 0.0
started := false
for i := range chunks {
if started && acc+chunks[i].EstOut > seg.EditCeilingOut {
*nextID++ // close the current unit at this chunk boundary
acc = 0
}
chunks[i].EditUnitID = *nextID
acc += chunks[i].EstOut
started = true
}
if started {
*nextID++ // advance past the last unit so the next chapter starts a fresh id
}
}
// subChunk is one sentence-packed sub-chunk with its char-class counts and the oversized-sentence
// flag (a single sentence whose est_out alone exceeds DraftBudgetOut).
type subChunk struct {
text string
cjk, other int
oversizedSentence bool
}
// packSentences splits one oversize paragraph into sentence segments (lossless tiling —
// concatenation reproduces the paragraph) and packs consecutive segments into ≤ DraftBudgetOut
// (est_out) sub-chunks, never splitting a sentence. A single sentence larger than the budget
// becomes its own sub-chunk flagged OversizedSentence (passthrough, never clause-split — the plan's
// §2б decision: "деградация не молчит" is satisfied by the FLAG, not emergency clause machinery).
func packSentences(paragraph string, seg SegBudget) []subChunk {
segs := splitSourceSentences(paragraph)
var chunks []subChunk
var buf strings.Builder
var bufCJK, bufOther int
flush := func() {
if buf.Len() == 0 {
return
}
chunks = append(chunks, subChunk{text: buf.String(), cjk: bufCJK, other: bufOther})
buf.Reset()
bufCJK, bufOther = 0, 0
}
for _, s := range segs {
sCJK, sOther := tokenClassCounts(s)
// A lone sentence over budget: flush what precedes it, then emit it as its own
// OversizedSentence sub-chunk (never split a sentence, and never merge it with a
// neighbour so the oversized flag stays attributable to the single sentence).
if seg.estOut(sCJK, sOther) > seg.DraftBudgetOut {
flush()
chunks = append(chunks, subChunk{text: s, cjk: sCJK, other: sOther, oversizedSentence: true})
continue
}
if buf.Len() > 0 && seg.estOut(bufCJK+sCJK, bufOther+sOther) > seg.DraftBudgetOut {
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
}
// 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')
}