textmachine/backend/internal/pipeline/wave.go

92 lines
4.7 KiB
Go
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.

package pipeline
import "strings"
// unitJoinSeparator joins a the edit wave edit-unit's member draft chunks into the editor's input draft AND (in
// export --pairs) their source chunks into the unit's source column. It MUST be identical on both sides so
// the exported src↔dst pair aligns byte-for-byte (export map §4). "\n\n" mirrors the chunker's own paragraph
// separator (chunkDraftChunks joins paragraphs with "\n\n"), so a re-joined unit reads as clean prose.
const unitJoinSeparator = "\n\n"
// editUnit is one the edit wave unit of edit work (WS2 decoupling: draft = small chunk, edit = coarse unit). It groups
// the WHOLE draft chunks sharing a Chunk.EditUnitID (assignEditUnits stamps them per chapter), so the unit's
// source/draft is exactly the concatenation of its members — no straddle, lossless reconstruction. It is
// addressed by (Chapter, FirstChunkIdx): FirstChunkIdx is the lowest ChunkIdx among the members, the
// LEADER chunk whose (chapter, chunk_idx, "edit") key carries the unit's single edit chunk_status row.
type editUnit struct {
EditUnitID int
Chapter int
FirstChunkIdx int
Members []Chunk // in ChunkIdx order (append order from SplitChunks is already sorted)
}
// sourceText is the unit's source: the members' source joined with unitJoinSeparator (the editor's
// bilingual {{text}} and the fresh the edit wave memory.Select both run over this whole-unit source).
func (u editUnit) sourceText() string {
parts := make([]string, len(u.Members))
for i, m := range u.Members {
parts[i] = m.Text
}
return strings.Join(parts, unitJoinSeparator)
}
// buildEditUnits groups the ordered draft chunks into edit units by EditUnitID (WS2). SplitChunks emits
// chunks in (chapter, chunk_idx) order and stamps a monotone book-global EditUnitID, so a single ordered
// pass yields units in book order with members in ChunkIdx order. Pure and deterministic.
func buildEditUnits(chunks []Chunk) []editUnit {
var units []editUnit
byID := map[int]int{} // EditUnitID → index into units
for _, ch := range chunks {
if idx, ok := byID[ch.EditUnitID]; ok {
units[idx].Members = append(units[idx].Members, ch)
continue
}
byID[ch.EditUnitID] = len(units)
units = append(units, editUnit{
EditUnitID: ch.EditUnitID, Chapter: ch.Chapter, FirstChunkIdx: ch.ChunkIdx,
Members: []Chunk{ch},
})
}
return units
}
// wave.go: the the precompute pass (deterministic, $0) pre-compute of the wave runner (WS1 §1б). This is the FIRST piece
// of the wave executor built ahead of the driver switch (R1 residual): the sticky-chain memory selection
// is a CROSS-CHUNK sequential dependency (a chunk's sticky_prev is the union of the prior chunks' exact
// matches in the same chapter), so the parallel waves cannot recompute it per-chunk — it must be
// precomputed once in the precompute pass. precomputeSticky replicates EXACTLY the sequential driver's inline computation
// (bookrun.go's per-chapter reset + memory.Select + sticky advance), and the sequential driver now
// CONSUMES it — so it is byte-identical by construction, and the golden test proves the injection bytes
// did not move. When the driver switches to waves (residual), the waves consume this same precompute.
//
// Select is a PURE deterministic function of (chunk, chapter, sticky_prev, budget, frozen-bank) with NO
// model output, so precomputing it reproduces byte-for-byte the injection the sequential runner rendered
// (parallelism does not change the bytes — the order is fixed and the input carries no model state).
// precomputeSticky computes the per-chunk memory selection for EVERY chunk in one ordered the precompute pass pass
// (WS1 §1б): the sticky window is reset at each chapter boundary (a new chapter is a scene change) and
// advanced by each chunk's exact matches, exactly as the sequential loop did inline. Returns a slice
// aligned index-for-index with `chunks`. A nil bank (report path / no glossary) yields zero-valued
// selections (no injection), so a caller can index it unconditionally.
func precomputeSticky(chunks []Chunk, mem *MemoryBank, budget int) []memorySelection {
out := make([]memorySelection, len(chunks))
if mem == nil {
return out
}
var stickyWin []map[string]injectionDisposition
prevChapter := 0
for i, ch := range chunks {
if ch.Chapter != prevChapter {
stickyWin = nil // chapter boundary: reset scene-inertia (A5)
prevChapter = ch.Chapter
}
sel := mem.Select(ch.Text, ch.Chapter, unionSticky(stickyWin), budget)
out[i] = sel
// Advance the sticky window with THIS chunk's exact matches (keep the last stickyDepth).
stickyWin = append(stickyWin, sel.activeIDs)
if len(stickyWin) > stickyDepth {
stickyWin = stickyWin[len(stickyWin)-stickyDepth:]
}
}
return out
}