textmachine/backend/internal/chunk/chunker.go

663 lines
30 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 chunk turns a source file into the ordered units of translation work: ingest decodes the
// book (txt/epub, with encoding detection and ruby capture) into per-chapter normalized text, and
// the chunker segments that text into draft chunks and groups them into edit units.
//
// Both halves are deterministic and versioned: identical input yields identical chunk boundaries,
// because those boundaries decide what goes on the wire — a shifted boundary is a re-translation of
// a book already paid for. All language-specific structure (chapter markers, CJK section numerals,
// heading rules, sentence terminators, abbreviations) arrives as DATA via *lang.Pack and the
// embedded language data, so a new pair needs no code here.
package chunk
import (
"strconv"
"strings"
"unicode"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/text"
)
// chunker.go: the step-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 (layer 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
// the coverage gate's splitSentences (internal/checks). 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, layer 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
// ("degradation is not silent") rather than silently oversized.
OversizedSentence bool
// Heading is the deterministic chapter title, non-empty ONLY on a chapter's FIRST chunk (ChunkIdx 0)
// when the chapter opened with a source structural header (RECOGNISED by the source language's
// lang.SourceStructure) AND the pair supplies a template. The chunker STRIPS the source marker from ch.Text (so the model never
// renders its own «Раздел 2»/«Первая глава»/an orphaned « :») and renders this from the rule's template
// («Глава N»); the read-models PREPEND it to the chapter's first output unit. "" for every non-first
// chunk, every chapter without a header, and every book without a heading rule (in which case the source
// header stays in ch.Text and chunking is byte-identical to before the feature existed).
Heading string
// 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 the edit wave's editor reads a unit's draft as
// the concatenation of its member chunks' the draft wave 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 text.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 (Phase-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 the edit wave unit is uniquely addressable.
func SplitChunks(chapters []string, seg SegBudget, rule *lang.ChapterRule, abbrevs map[string]bool) []Chunk {
chunks, _, _ := SplitChunksWithChapters(chapters, seg, rule, abbrevs)
return chunks
}
// SplitChunksWithChapters is SplitChunks plus the INGESTED text of every chapter that consumed a number,
// index-aligned to the chapter numbers it emitted (kept[0] is chapter 1). It exists so a caller that has
// to identify a chapter — the persisted manifest, backlog row 100 — gets the chapter's own text from the
// ONE place that knows which chapters consume a number, instead of re-deriving that rule and drifting
// from it the first time an "empty chapter" edge case changes.
//
// The text returned is the chapter AS INGESTED — before stripHeading. That is deliberate: an identity
// built on it survives a heading-rule edit (a data change in the pair pack) and a chunker/budget change,
// because neither touches what ingest produced. Nothing else here changes: SplitChunks is this function
// with the extra results dropped, so every existing caller is byte-identical.
//
// keptIdx gives each kept chapter's index in the INPUT slice. It exists so a caller holding data parallel to
// the input — the per-chapter source titles — can select the same chapters WITHOUT re-deriving the
// "an empty chapter takes no number" rule. Re-deriving it is how a title ends up one chapter late, and that
// error is invisible: every chapter still has A title.
func SplitChunksWithChapters(chapters []string, seg SegBudget, rule *lang.ChapterRule, abbrevs map[string]bool) (chunks []Chunk, kept []string, keptIdx []int) {
var out []Chunk
chapterNo := 0
editUnitID := 0
for idx, ingested := range chapters {
// Title policy (pack-13): detect a leading structural header, render it deterministically and
// STRIP its marker from the text the model sees. An absent TEMPLATE (no pack / no heading.txt) or a chapter
// with no header is a NO-OP — headingText is "" and chapText is unchanged, so a book that does not
// opt in produces byte-identical chunks. Runs BEFORE splitParagraphs so the stripped subtitle is
// re-paragraphed normally.
headingText, chapText := stripHeading(ingested, rule)
paras := splitParagraphs(chapText)
if len(paras) == 0 {
continue // an empty chapter does not consume a chapter number
}
chapterNo++
kept = append(kept, ingested)
keptIdx = append(keptIdx, idx)
chapterChunks := chapterDraftChunks(chapterNo, paras, seg, abbrevs)
if headingText != "" && len(chapterChunks) > 0 {
chapterChunks[0].Heading = headingText // the chapter's first chunk carries the deterministic title
}
assignEditUnits(chapterChunks, seg, &editUnitID)
out = append(out, chapterChunks...)
}
return out, kept, keptIdx
}
// stripHeading detects a chapter-leading structural header and returns the DETERMINISTIC rendered title
// plus the chapter text with the source marker removed (pack-13 title policy). It reads a ChapterRule: the
// SOURCE grammar recognises the header, the PAIR template renders it.
//
// The no-op condition is an ABSENT TEMPLATE, not a nil rule — the runner always builds a rule, because every
// book has a source language even when it has no pair pack. No template, no header, or no grammar: it
// returns ("", chapter) unchanged and the chunks are byte-identical to a run without the feature. The
// marker+numeral+unit prefix is stripped and the SUBTITLE (if any) is kept as ordinary body — so «第一节:
// 纵身亡魔心仍不悔» yields ("Глава 1", "纵身亡魔心仍不悔\n…"): the reader gets a uniform «Глава N» PLUS the
// translated subtitle, and the model never renders the chapter number. Only the FIRST line is inspected
// (a header is a chapter's opening line; a chapter with a preamble before its header is a rare edge left
// to the pre-pack behaviour, noted in the pack report). Deterministic and pure.
func stripHeading(chapter string, cr *lang.ChapterRule) (heading, stripped string) {
if cr == nil || cr.Template == "" {
return "", chapter
}
parts := strings.SplitN(chapter, "\n", 2)
n, subtitle, ok := matchHeaderLine(parts[0], cr)
if !ok {
return "", chapter
}
heading = strings.ReplaceAll(cr.Template, "{n}", strconv.Itoa(n))
rest := ""
if len(parts) == 2 {
rest = parts[1]
}
switch {
case subtitle == "":
return heading, strings.TrimLeft(rest, "\n") // the whole header line + its trailing blank go away
case rest == "":
return heading, subtitle
default:
return heading, subtitle + "\n" + rest
}
}
// ApplyHeading prepends a chapter's DETERMINISTIC title to its first output unit's final text (pack-13
// title policy). It is a PURE, deterministic projection applied at assembly time (waverun outcome +
// export), NEVER stored in a checkpoint — so a resume re-derives it for free from the manifest re-chunk,
// and a book with no heading rule (heading=="") is byte-identical. It prepends ONLY to a NON-EMPTY final
// text: a fully-flagged/empty unit keeps "" (the "not translated" export semantics), and the title
// reappears when the unit is redriven ok. The separator is a blank line so «Глава N» reads as a heading
// above the (subtitle + body) prose.
func ApplyHeading(heading, finalText string) string {
if heading == "" || finalText == "" {
return finalText
}
return heading + "\n\n" + finalText
}
// matchHeaderLine reports whether a single line is a structural header under the rule and returns the
// parsed section number + the trimmed subtitle after the marker. Shape: <marker><numeral-run><unit-rune>,
// then EITHER end-of-line OR a SEPARATOR (not a content glyph) — mirroring ingest.isChapterHeader's
// precision guard so «第一回见面» (回 a measure word glued to the content 见) is NOT a header, while
// «第一节:…» / «第1章 …» is. Leading whitespace is tolerated; the subtitle is the remainder with any
// leading separators (: 、,.。- —  ·) trimmed. Deterministic.
func matchHeaderLine(line string, cr *lang.ChapterRule) (n int, subtitle string, ok bool) {
t := strings.TrimSpace(line)
st := cr.Structure
afterMarker, ok := st.MatchMarker(t)
if !ok {
return 0, "", false
}
rs := []rune(afterMarker)
// Tolerate whitespace between the marker and the number («Chapter 12», «Глава 3»). A CJK header («第12章»)
// has none, so the skip is additive.
j := 0
for j < len(rs) && unicode.IsSpace(rs[j]) {
j++
}
i := j
for i < len(rs) && isHeadingNumeral(rs[i]) {
i++
}
if i == j { // need at least one numeral
return 0, "", false
}
num, parsed := parseSectionNumeral(string(rs[j:i]))
if !parsed {
return 0, "", false
}
after := rs[i:]
if len(st.Units) > 0 {
// A unit-bearing rule (CJK 章/节/…) REQUIRES the unit rune right after the number — the guard that
// keeps «第一次» (a measure word) from reading as a header.
if len(after) == 0 || !st.Units[after[0]] {
return 0, "", false
}
after = after[1:]
}
if len(after) > 0 && isHeaderContentRune(after[0]) {
return 0, "", false // a content glyph glued to the number/unit → a measure/ordinal in prose, not a header
}
return num, strings.TrimSpace(strings.TrimLeftFunc(string(after), isHeadingSeparator)), true
}
// isHeadingNumeral reports whether a rune can be part of a chapter-number run: an Arabic digit
// (half/fullwidth) or a CJK numeral character (the shared lang.CJKSection — pair-14 §4, the SAME source
// ingest's chapter-numeral regex reads, so the two can never byte-drift apart).
func isHeadingNumeral(r rune) bool {
switch {
case r >= '0' && r <= '9', r >= '' && r <= '':
return true
}
return lang.DefaultCJKSection().IsHeadingNumeral(r)
}
// isHeaderContentRune reports whether a rune is CONTENT (a letter/digit/ideograph/kana) rather than a
// separator — the guard that keeps a glued measure word from reading as a header (ingest parity). It is the
// single source of truth for the content/separator split: ingest.isHeaderSeparator is its exact complement.
func isHeaderContentRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) ||
unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r)
}
// isHeadingSeparator reports whether a rune separates the marker from the subtitle (trimmed off the head
// of the subtitle): CJK/ASCII colons, commas, periods, dashes, the ideographic space and middle dots. This
// is a DISTINCT, narrower whitelist — NOT the complement of isHeaderContentRune (that is isHeaderSeparator).
func isHeadingSeparator(r rune) bool {
switch r {
case '', ':', '、', '', ',', '.', '。', '-', '—', '', ' ', '\t', ' ', '·', '・':
return true
}
return false
}
// parseSectionNumeral parses a chapter-number run into an int (Arabic half/fullwidth OR standard CJK
// numerals up to the thousands — well past any chapter count). A digit accumulates positionally; a small
// unit (十百千) flushes the pending coefficient (十 alone = 10). Returns ok=false on an unparseable rune
// or a non-positive result (conservative — an unparseable header simply is not stripped/rendered).
func parseSectionNumeral(s string) (int, bool) {
section, num := 0, 0
any := false
for _, r := range s {
switch {
case r >= '0' && r <= '9':
num = num*10 + int(r-'0')
any = true
case r >= '' && r <= '':
num = num*10 + int(r-'')
any = true
case lang.DefaultCJKSection().Zero[r]:
num = num * 10
any = true
default:
if d, isDigit := cjkSectionDigit(r); isDigit {
num = num*10 + d
any = true
continue
}
if u, isUnit := cjkSectionUnit(r); isUnit {
if num == 0 {
num = 1
}
section += num * u
num = 0
any = true
continue
}
return 0, false
}
}
v := section + num
if !any || v <= 0 {
return 0, false
}
return v, true
}
// cjkSectionDigit / cjkSectionUnit read the shared lang.CJKSection (pair-14 §4): the SAME digit/unit value
// tables the ingest chapter-numeral inventory derives from, so there is ONE source of truth.
func cjkSectionDigit(r rune) (int, bool) {
v, ok := lang.DefaultCJKSection().Digit[r]
return v, ok
}
func cjkSectionUnit(r rune) (int, bool) {
v, ok := lang.DefaultCJKSection().Unit[r]
return v, ok
}
// 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, abbrevs map[string]bool) []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, abbrevs) {
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 the edit wave 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: "degradation is not silent" is satisfied by the FLAG, not emergency clause machinery).
func packSentences(paragraph string, seg SegBudget, abbrevs map[string]bool) []subChunk {
segs := splitSourceSentences(paragraph, abbrevs)
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) {
return text.DenseSparseCounts(s) // one shared sizing taxonomy (text.DenseScript)
}
// 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, abbrevs map[string]bool) []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], abbrevs) {
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). The classes are DATA (lang.DefaultTerminators) shared with the coverage gate,
// which counts sentences over the same alphabet — the two used to carry a copy each.
func isSourceTerminator(r rune) bool { return lang.DefaultTerminators().IsTerminator(r) }
// 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 { return lang.DefaultTerminators().IsCJK(r) }
// 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
}
// The abbreviation SET (trailing tokens after which a lone "." is an abbreviation, not a sentence end) is
// SOURCE data (internal/lang, embedded, sectioned per SOURCE language, pair-14 §4). It is now
// resolved from the BOOK's source language (lang.SentenceAbbrev(sourceLang), threaded through SplitChunks) —
// no longer the hard-coded "en" section — so a source whose ASCII period needs the guard supplies its own
// list, no Go edit. A CJK source (。 terminators) ships none, so the set is empty and never consulted. Single-
// letter initials are handled separately, script-agnostic, below.
// isAbbrevBefore reports whether the text immediately before a lone "." ends in a known abbreviation (from
// the source-language set) or a single-letter initial (so the "." is not a boundary).
func isAbbrevBefore(left []rune, abbrevs map[string]bool) 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 abbrevs[strings.ToLower(string(word))]
}
func isASCIILetter(r rune) bool {
return (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z')
}