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 }