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