From 488c8d4368f615c7737c802927b4badfe7f16b8d Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sun, 19 Jul 2026 21:40:44 +0300 Subject: [PATCH] Land c-lite edit-unit blast-radius fix (D39.18): a flagged draft member is dropped and the editor runs over the clean remainder instead of blanking the whole unit; align export/status/quality read-models on the dropped member; no-flag path byte-identical (golden stable) --- backend/internal/pipeline/export.go | 41 ++++- backend/internal/pipeline/quality.go | 33 ++++ backend/internal/pipeline/status.go | 9 +- backend/internal/pipeline/waverun.go | 49 ++++-- backend/internal/pipeline/waverun_test.go | 94 ++++++++++- .../R1_POST_LANDING_REVIEW_2026-07-19.md | 154 ++++++++++++++++++ 6 files changed, 352 insertions(+), 28 deletions(-) create mode 100644 docs/archive/reports/R1_POST_LANDING_REVIEW_2026-07-19.md diff --git a/backend/internal/pipeline/export.go b/backend/internal/pipeline/export.go index 2d71e648..cf54201a 100644 --- a/backend/internal/pipeline/export.go +++ b/backend/internal/pipeline/export.go @@ -2,6 +2,7 @@ package pipeline import ( "fmt" + "strings" "textmachine/backend/internal/store" ) @@ -125,6 +126,18 @@ func (r *Runner) Export(pairs bool) (*BookExport, error) { return nil, err } units := r.outputUnits(manifest) + // c-lite (D39.17-fix): the wave editor edits the CLEAN members of a unit even when one member draft flagged + // — the edit row is DispOK and SHIPS the edited clean remainder, but the UNIT is flagged because a member + // was dropped. Mirror status.resolveChunkState: re-derive "a member draft flagged ⇒ the unit is flagged" + // from the member draft rows, so export/status/translate agree while the edited text still ships. + draftStageNames := stageNameSet(r.waveStagesIndexed(waveDraft)) + type droppedMember struct{ reason, detail string } + memberDrop := map[chunkKey]droppedMember{} // a flagged member draft's reason+detail, keyed by its draft chunk + for _, cs := range statuses { + if draftStageNames[cs.Stage] && cs.Disposition == string(DispFlagged) { + memberDrop[chunkKey{cs.Chapter, cs.ChunkIdx}] = droppedMember{cs.FlagReason, cs.Detail} + } + } exp := &BookExport{BookID: r.Book.BookID, Chunks: []ChunkExport{}} inManifest := map[chunkKey]bool{} for _, u := range units { @@ -145,8 +158,35 @@ func (r *Runner) Export(pairs bool) (*BookExport, error) { if err != nil { return nil, err } + // c-lite: an ok edit row over a unit that DROPPED a flagged member is a flagged (incomplete) unit — mark + // it so (the first dropped member's reason AND detail, so the record is self-consistent, not the ok edit's + // blank detail), keeping ce.FinalText (the edited clean remainder). Mirrors status.resolveChunkState. + droppedAny := false + if ce.Disposition == string(DispOK) { + for _, m := range u.Members { + if dm, dropped := memberDrop[chunkKey{m.Chapter, m.ChunkIdx}]; dropped { + ce.Disposition = string(DispFlagged) + ce.FlagReason = dm.reason + ce.Detail = dm.detail + droppedAny = true + break + } + } + } if pairs { ce.Source = u.sourceText() // the unit src↔target column for the DC1/DC2 FP-measure (--pairs) + if droppedAny { + // The target is the edit over the CLEAN members only — align the --pairs source to match (exclude + // the dropped members, byte-for-byte as runEditUnit's cleanSources join) so the DC1/DC2 FP-measure + // sees a real src↔target pair, not the dropped member's source terms with no target counterpart. + var clean []string + for _, m := range u.Members { + if _, dropped := memberDrop[chunkKey{m.Chapter, m.ChunkIdx}]; !dropped { + clean = append(clean, m.Text) + } + } + ce.Source = strings.Join(clean, unitJoinSeparator) + } } exp.Chunks = append(exp.Chunks, ce) } @@ -167,7 +207,6 @@ func (r *Runner) Export(pairs bool) (*BookExport, error) { // wave means `translate` would --resnapshot (a draft-config change re-pins the draft wave and cascades // to the edit via content-hash). Only meaningful when a wave's rows carry a single snapshot (a // mid-book multi-snapshot drift is already visible in the per-row snapshot_id). - draftStageNames := stageNameSet(r.waveStagesIndexed(waveDraft)) editStageNames := stageNameSet(r.waveStagesIndexed(waveEdit)) draftSnaps, editSnaps := map[string]bool{}, map[string]bool{} for _, cs := range statuses { diff --git a/backend/internal/pipeline/quality.go b/backend/internal/pipeline/quality.go index fd70910a..7942ebf2 100644 --- a/backend/internal/pipeline/quality.go +++ b/backend/internal/pipeline/quality.go @@ -120,6 +120,27 @@ func (r *Runner) QualityReport() (*QualityReport, error) { inManifest[chunkKey{u.Chapter, u.FirstChunkIdx}] = true } + // c-lite (D39.17-fix): the wave editor drops a flagged member and edits the clean remainder, so the leader + // edit row is DispOK even though a member's draft echoed/stripped. Re-derive the dropped member's reason per + // unit (like export/status) so the echo/strip RATES still count it — else a unit RECOVERED by editing the + // clean remainder would vanish from EchoRate/CosmeticStripRate the owner reads for the acceptance planka. + draftStageNames := stageNameSet(r.waveStagesIndexed(waveDraft)) + memberDraftFlag := map[chunkKey]string{} + for _, cs := range statuses { + if draftStageNames[cs.Stage] && cs.Disposition == string(DispFlagged) { + memberDraftFlag[chunkKey{cs.Chapter, cs.ChunkIdx}] = cs.FlagReason + } + } + droppedReasonByLeader := map[chunkKey]string{} + for _, u := range units { + for _, m := range u.Members { + if reason, dropped := memberDraftFlag[chunkKey{m.Chapter, m.ChunkIdx}]; dropped { + droppedReasonByLeader[chunkKey{u.Chapter, u.FirstChunkIdx}] = reason + break + } + } + } + rep := &QualityReport{BookID: r.Book.BookID, TotalChunks: len(units)} byChunk := map[chunkKey]*ChunkQuality{} order := []chunkKey{} @@ -195,6 +216,18 @@ func (r *Runner) QualityReport() (*QualityReport, error) { if cs.FlagReason == string(FlagSanitizerStripped) { rep.CosmeticStripChunks++ // a stripped chunk carried a markdown OR CJK cosmetic leak (F6) } + // c-lite: a DispOK leader edit row whose unit dropped a member counts toward the same rates by the + // dropped member's reason — the echo/strip happened in a real (dropped) draft, recovered by editing the + // rest. Disjoint from the two checks above (they need a flagged edit row; this needs a DispOK one). + if cs.Disposition == string(DispOK) { + switch droppedReasonByLeader[k] { + case string(FlagCJKArtifact): + rep.EchoChunks++ + chunkOf(k) + case string(FlagSanitizerStripped): + rep.CosmeticStripChunks++ + } + } // Structural KPI: recompute over the exported final text (ok, or the cosmetic-stripped export). if cs.FinalHash == "" { continue diff --git a/backend/internal/pipeline/status.go b/backend/internal/pipeline/status.go index a80f16fc..c5c5f332 100644 --- a/backend/internal/pipeline/status.go +++ b/backend/internal/pipeline/status.go @@ -165,8 +165,13 @@ func resolveChunkState(rows []store.ChunkStatus, stagesTotal int) (st ChunkState case string(DispOK): ok++ case string(DispFlagged): - // The first (only) flagged stage decides the chunk; keep its reason. - st, reason = ChunkFlagged, cs.FlagReason + // The FIRST flagged stage decides the chunk; keep its reason (the rows arrive in member/stage order + // with the leader bucket first, so first-wins aligns status with translate/export, which both take + // the first flag — a member drop's reason, or the edit's own when the edit flagged). Without the + // guard the loop's last-flagged row would win, diverging from the other read-models on a multi-flag unit. + if st != ChunkFlagged { + st, reason = ChunkFlagged, cs.FlagReason + } case string(DispSkipped): skipped++ } diff --git a/backend/internal/pipeline/waverun.go b/backend/internal/pipeline/waverun.go index 2c22975e..a0334eb9 100644 --- a/backend/internal/pipeline/waverun.go +++ b/backend/internal/pipeline/waverun.go @@ -370,21 +370,26 @@ func (r *Runner) runDraftChunk(ctx context.Context, draftSnapshot string, ch Chu // runEditUnit runs the editor stage(s) over one edit unit under the edit-wave snapshot (plan §1/§2). It reads // the unit's draft as the concatenation of its member chunks' draft outputs (NEVER re-rendering the draft -// stage — the несущий invariant), does a FRESH memory.Select over the WHOLE-unit source over the ENRICHED -// bank (sticky degenerate at unit scope → nil), and runs the editor over (unit source, unit draft). A unit whose -// ANY member draft flagged is flagged (skip edit — no paid edit over a partial draft, D2 flag+skip); the -// good members' draft-wave spend stays honestly committed. Post-check + cheap gates run over the edit output at -// unit granularity, merged into the LEADER chunk's retrieval_state row. +// stage — the несущий invariant), does a FRESH memory.Select over the unit source over the ENRICHED bank +// (sticky degenerate at unit scope → nil), and runs the editor over (unit source, unit draft). Post-check + +// cheap gates run over the edit output at unit granularity, merged into the LEADER chunk's retrieval_state row. +// +// A flagged member draft is DROPPED from the edit, not blanked-whole-unit (c-lite, D39.17-fix): the editor +// still runs over the CLEAN members' source+draft, so the good (already-paid) drafts are edited and shipped — +// only when EVERY member flagged does the unit ship "". The edit row records the edit's honest outcome; the +// UNIT is flagged whenever a member dropped (or the edit itself flagged), a fact the export/status read-models +// re-derive from the member draft rows (status.resolveChunkState) so all three agree. The pre-c-lite behaviour +// (any flagged member → whole unit blank) discarded a chapter-scale unit + its paid sibling drafts over one bad +// chunk — a regression from the retired per-chunk model, closed here. func (r *Runner) runEditUnit(ctx context.Context, editSnapshot string, unit editUnit, draftByKey map[chunkKey]stageSeqResult, editStages []wavedStage) (*ChunkOutcome, error) { - leader := Chunk{Chapter: unit.Chapter, ChunkIdx: unit.FirstChunkIdx, Text: unit.sourceText()} out := &ChunkOutcome{Chapter: unit.Chapter, ChunkIdx: unit.FirstChunkIdx, Disposition: DispOK} var draftStages []StageResult var draftCost float64 - var draftParts []string + var cleanSources []string // the non-flagged members' source (the editor's {{text}}, aligned to the clean draft) + var draftParts []string // the non-flagged members' draft (the editor's input) memberFlagged := false var memberFlagReason FlagReason - memberRecovered := "" for _, m := range unit.Members { d, ok := draftByKey[chunkKey{m.Chapter, m.ChunkIdx}] if !ok { @@ -396,17 +401,18 @@ func (r *Runner) runEditUnit(ctx context.Context, editSnapshot string, unit edit if !memberFlagged { memberFlagged = true memberFlagReason = d.flagReason - memberRecovered = d.recovered } - continue + continue // the flagged member is DROPPED from the edit — not skipped-whole-unit (c-lite) } + cleanSources = append(cleanSources, m.Text) draftParts = append(draftParts, d.finalText) } out.CostUSD = draftCost - if memberFlagged { - // A member draft flagged → the unit's edit is skipped (no paid edit over a partial draft). Record a - // skipped edit chunk_status at the leader and export the recovered draft (or "" for a dropped flag). + // Only when EVERY member draft flagged is there nothing clean to edit → skip the edit and ship "" (the whole + // unit is unusable). Record a skipped edit chunk_status at the leader so the read-models see a flagged unit. + if len(draftParts) == 0 { + leader := Chunk{Chapter: unit.Chapter, ChunkIdx: unit.FirstChunkIdx} skipped, err := r.recordSkippedStages(ctx, editStages, editSnapshot, leader, memberFlagReason) if err != nil { return nil, err @@ -414,10 +420,14 @@ func (r *Runner) runEditUnit(ctx context.Context, editSnapshot string, unit edit out.Stages = append(draftStages, skipped...) out.Disposition = DispFlagged out.FlagReason = memberFlagReason - out.FinalText = exportNormalize(memberRecovered) + out.FinalText = "" return out, nil } + // The edit is ADDRESSED at the manifest leader (chapter, FirstChunkIdx — where the read-models look for the + // unit's final row) but its SOURCE + DRAFT are the CLEAN members only. With NO flagged member cleanSources + // join == unit.sourceText() byte-for-byte, so the normal path (and the golden) is unchanged. + leader := Chunk{Chapter: unit.Chapter, ChunkIdx: unit.FirstChunkIdx, Text: strings.Join(cleanSources, unitJoinSeparator)} unitDraft := strings.Join(draftParts, unitJoinSeparator) var editSel memorySelection injectionByRole := map[string]string{} @@ -467,11 +477,18 @@ func (r *Runner) runEditUnit(ctx context.Context, editSnapshot string, unit edit } } - if flagged { + // Unit disposition: the EDIT itself flagged → ship its recovered/""; else a MEMBER dropped → still ship the + // edited clean remainder, flagged for review (c-lite); else clean → ship the edit output ok. + switch { + case flagged: out.Disposition = DispFlagged out.FlagReason = flagReason out.FinalText = exportNormalize(recovered) - } else { + case memberFlagged: + out.Disposition = DispFlagged + out.FlagReason = memberFlagReason + out.FinalText = exportNormalize(finalText) + default: out.FinalText = exportNormalize(finalText) } return out, nil diff --git a/backend/internal/pipeline/waverun_test.go b/backend/internal/pipeline/waverun_test.go index d4347c6d..2010347c 100644 --- a/backend/internal/pipeline/waverun_test.go +++ b/backend/internal/pipeline/waverun_test.go @@ -194,9 +194,10 @@ func TestWaveParallelWorkersMoneyConserved(t *testing.T) { } } -// TestWaveEditUnitFlaggedMemberDraft: when ONE member draft of a multi-chunk unit flags, the whole unit is -// flagged and its edit is SKIPPED (no paid edit over a partial draft), while the good member's draft money -// stays committed. There is exactly ONE edit call fewer than a clean run. +// TestWaveEditUnitFlaggedMemberDraft: when ONE member draft of a multi-chunk unit flags, the flagged member is +// DROPPED but the editor STILL runs over the CLEAN member(s) (c-lite) — the good, already-paid draft is edited +// and shipped, the UNIT is flagged for review (the dropped member's reason), the good member's draft money +// stays committed, and export agrees. Regression guard: the pre-c-lite code blanked the whole chapter-scale unit. func TestWaveEditUnitFlaggedMemberDraft(t *testing.T) { rec := &reqRec{} // The second paragraph carries a marker; the mock returns an untranslated CJK echo for it → cjk_artifact. @@ -217,7 +218,6 @@ func TestWaveEditUnitFlaggedMemberDraft(t *testing.T) { ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) r := newRunner(t, bookPath) - defer r.Close() res, err := r.TranslateBook(ctx) if err != nil { t.Fatal(err) @@ -226,15 +226,91 @@ func TestWaveEditUnitFlaggedMemberDraft(t *testing.T) { t.Fatalf("still one unit outcome, got %d", len(res.Chunks)) } oc := res.Chunks[0] + // The unit is FLAGGED (a member dropped) with the dropped member's reason — but it SHIPS the edited clean + // remainder, not "" (c-lite: don't blank a chapter over one bad chunk). if oc.Disposition != DispFlagged || oc.FlagReason != FlagCJKArtifact { - t.Fatalf("a flagged member draft must flag the unit (cjk_artifact), got %s/%s", oc.Disposition, oc.FlagReason) + t.Fatalf("a dropped member draft must flag the unit (cjk_artifact), got %s/%s", oc.Disposition, oc.FlagReason) } - if oc.FinalText != "" { - t.Fatalf("a flagged unit exports nothing, got %q", oc.FinalText) + if oc.FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" { + t.Fatalf("the unit must ship the edited CLEAN remainder, got %q", oc.FinalText) } - // 2 draft calls (one ok, one echo), ZERO edit calls — the unit's edit was skipped over the partial draft. + // 2 draft calls (one ok, one echo) + 1 edit over the CLEAN member — the flagged member was dropped, not the + // whole unit skipped. + if rec.count() != 3 { + t.Fatalf("a dropped-member unit runs 2 drafts + 1 clean edit, got %d calls", rec.count()) + } + // The editor read ONLY the clean member's draft — never the echoed member. + var editBody string + for _, b := range rec.all() { + if isEditBody(b) { + editBody = b + } + } + if strings.Count(editBody, "ЧЕРНОВИК ПЕРЕВОДА") != 1 || strings.Contains(editBody, "完全没有翻译") { + t.Fatalf("the edit must carry ONLY the clean member's draft (not the dropped echo)") + } + r.Close() + + // Export agrees: the unit is flagged (a member dropped) yet SHIPS the edited clean remainder. + r2 := newRunner(t, bookPath) + defer r2.Close() + exp, err := r2.Export(false) + if err != nil { + t.Fatal(err) + } + if len(exp.Chunks) != 1 { + t.Fatalf("export must project one unit, got %d", len(exp.Chunks)) + } + if exp.Chunks[0].Disposition != string(DispFlagged) || exp.Chunks[0].FlagReason != string(FlagCJKArtifact) { + t.Fatalf("export unit must be flagged (dropped member), got %s/%s", exp.Chunks[0].Disposition, exp.Chunks[0].FlagReason) + } + if exp.Chunks[0].FinalText != "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД" { + t.Fatalf("export must ship the edited clean remainder for a dropped-member unit, got %q", exp.Chunks[0].FinalText) + } + // The dropped member's echo must NOT vanish from the quality telemetry the owner reads for acceptance + // (c-lite makes the leader edit row DispOK; quality re-derives the drop like export/status). + q, err := r2.QualityReport() + if err != nil { + t.Fatal(err) + } + if q.EchoChunks != 1 { + t.Fatalf("the dropped member's echo must still count in QualityReport (EchoRate not silently regressed), got EchoChunks=%d", q.EchoChunks) + } +} + +// TestWaveEditUnitAllMembersFlagged: when EVERY member draft of a multi-chunk unit flags there is nothing clean +// to edit — the unit ships "" and runs ZERO edit calls (the one case c-lite still blanks a unit). +func TestWaveEditUnitAllMembersFlagged(t *testing.T) { + rec := &reqRec{} + const echoMarker = "禁" + respond := func(body string) (string, string) { + if isEditBody(body) { + return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" + } + return "这是完全没有翻译的中文内容。", "stop" // every draft echoes → cjk_artifact + } + srv := newJSONProvider(rec, respond) + defer srv.Close() + src := strings.Repeat(echoMarker, 1400) + "。\n\n" + strings.Repeat(echoMarker, 1100) + "。" + bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: src, regenerate: 0}) + ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}) + + r := newRunner(t, bookPath) + defer r.Close() + res, err := r.TranslateBook(ctx) + if err != nil { + t.Fatal(err) + } + if len(res.Chunks) != 1 { + t.Fatalf("one unit outcome, got %d", len(res.Chunks)) + } + oc := res.Chunks[0] + if oc.Disposition != DispFlagged || oc.FinalText != "" { + t.Fatalf("an all-flagged unit ships nothing, got %s / %q", oc.Disposition, oc.FinalText) + } + // 2 draft calls (both echo), ZERO edit calls — nothing clean to edit. if rec.count() != 2 { - t.Fatalf("a partial-draft unit runs 2 drafts + 0 edit, got %d calls", rec.count()) + t.Fatalf("an all-flagged unit runs 2 drafts + 0 edit, got %d calls", rec.count()) } } diff --git a/docs/archive/reports/R1_POST_LANDING_REVIEW_2026-07-19.md b/docs/archive/reports/R1_POST_LANDING_REVIEW_2026-07-19.md new file mode 100644 index 00000000..c0be2abc --- /dev/null +++ b/docs/archive/reports/R1_POST_LANDING_REVIEW_2026-07-19.md @@ -0,0 +1,154 @@ +# R1 wave-driver — post-landing review (bugs + concurrency + functional) + +**Session:** backend, 2026-07-19, on top of the landed R1 driver-switch (`89d5bf9`). +**Ask (owner):** a clean functional + concurrency-bug look at the wave feature — "what happens when a book is +loaded into the backend" — plus three owner findings to fold in (R1-FL-A/B/C). +**Method:** own source trace + **4 independent adversarial reviewers** (author≠reviewer) on distinct lenses +(money · store/SQLite · wave-logic · end-to-end functional), then a **verification workflow** (3 lenses + +synthesis) over the fix I wrote. Every reviewer finding was re-verified against source before acting. +**Status:** two LIVE fixes applied to the working tree (UNCOMMITTED — orchestrator lands); R1-FL-A/B/C deferred +to the pere-run mining batch per owner. `go build/vet`, full `go test -race ./...`, golden byte-stable. + +--- + +## 1. Concurrency verdict — SOUND (3 reviewers + trace agree) + +No money-correctness, budget, resume, or data-race defect on the concurrency axis. Confirmed at `file:line`: + +- **Money atomic under the single writer.** `Reserve`/`SettleWithCheckpoint` are single `BEGIN IMMEDIATE` + transactions on the 1-connection write pool (`store.go:71`, `ledger.go:50`) → `committed == Σ settled`; two + parallel reserves fully serialize (no ceiling skip). Settle uses a fresh `opContext` (not the wave ctx), so a + cancelling wave still persists a paid 2xx; a `kill-9` loses ≤ N in-flight, each re-paid once (`recoverReservations`). +- **Escalation soft-cap exact.** `escMu` is held across the fresh hop's settle (`escalation.go:119-128`); the next + worker's read-pool `EscalationSpentUSD` sees the committed hop (WAL read-after-commit across the mutex) → + overshoot ≤ 1 hop. +- **No reservation leak**: every post-`Reserve` exit settles or releases (`stagerun.go:377/382/393/422/443/483`). +- **Shared chapter×stage job row is advisory-only** (`EnsureJob` keyed `(book,chapter,stage)`): `chunk_status`/ + checkpoints are the resume authority; `jobs.status` thrash is telemetry, nothing gates on it. +- **`retrieval_state` RMW race-free**: each unit owns a distinct leader `(chapter,FirstChunkIdx)`; waves are + sequential (barrier at `wg.Wait()`). +- **No shared mutable Runner field written during a wave**; `MemoryBank.Select/postcheck` are pure reads over + immutable state; `roleInjectionRenderers` read-only; `ReqInfo` value-typed (no ctx aliasing). +- **«переоплата ОДНА» airtight at BOTH levels**: the draft injection selects over `r.baseMemory` (Source≠mined) + and the draft snapshot folds `r.memory.BaseVersion()` (same mined-exclusion) — a mining sign moves only the + edit snapshot. + +--- + +## 2. Bug FIXED — `runWave` swallowed parent cancellation (the one real defect, live) + +The single concurrency bug all reviewers-but-one missed on the first pass — the wave-logic lens caught it. + +`runWave` (`waverun.go`) set `firstErr` **only** from a worker error and returned it bare after `wg.Wait()` — +no `parent.Err()` check. The feeder drops items on `ctx.Done()`, and a worker need not error to finish (a **$0 +resume** worker resolves entirely through `opContext()` store reads, never touching the cancellable ctx). So a +**Ctrl-C / SIGTERM** (`main.go:55` `signal.NotifyContext`) whose in-flight workers don't error → `runWave` +returns `nil` with items undone → +- **edit pipeline (prod C1 default):** `unitOutcomes[i]==nil` → `*oc` **nil-deref panic**; +- **draft-only:** zero-value → fake `DispOK ""` → **exit 0 on an incomplete book**. + +Reliably hits on a Ctrl-C'd resume. The durable store was always correct (undone items never checkpointed → a +re-run resumes), so no data/money loss — the damage is a crash / a false success signal. + +**Fix:** `return parent.Err()` after `wg.Wait()` when `firstErr==nil`. **Proven by execution:** +`TestRunWaveSurfacesParentCancellation` fails red on the old code, green on the fix. + +--- + +## 3. Owner-escalated DECISION → implemented — edit-unit flag blast radius (c-lite) + +**The live behavioural consequence of the chunk→unit shift.** Pre-fix, in `runEditUnit` one flagged draft member +skipped the whole unit's edit and shipped `exportNormalize(memberRecovered)` — always `""` for a draft stage +(the sanitizer only fills `recovered` on `isFinal`). So a single bad draft chunk **blanked a whole chapter-scale +unit**, discarding the good, already-paid sibling drafts — a regression from the retired per-chunk model. + +**Chosen (owner deferred the call; owner leaned toward option c):** **edit-clean-remainder** ("c-lite"). A flagged +member is DROPPED; the editor runs over the CLEAN members' source+draft, addressed at the manifest leader; the +unit SHIPS the edited clean remainder but is FLAGGED (a member dropped). Only an all-members-flagged unit ships "". + +**Why c-lite over a full positional split:** any "ship edited content" option forces export to become member-aware +(today's contract is "flagged unit → edit row non-OK → ships nothing"). A **full split** (N sub-edits with in-place +gaps) additionally forces every read-model to reconstruct the sub-run partition from stored draft dispositions and +makes `status`'s `expected`-stage-count **flag-dependent** — the fragile surface where read-model bugs breed. +c-lite keeps ONE edit row per unit (edit row stays DispOK → resume unchanged), so `status` and the golden are +untouched. **Byte-identical to a full split whenever the flagged member is at a unit edge** (the common case); +differs only for a mid-unit flag (flanking clean pieces edited as one joined block, not positioned sub-edits) — a +negligible artifact on a rare, human-reviewed flagged unit. **One cost:** redriving a dropped member later +re-edits over the now-complete unit (one rare wasted editor call). + +**Files:** `runEditUnit` (drop flagged members, edit clean remainder, ship flagged); `export.go` re-derives +"dropped member ⇒ flagged unit" from member draft rows so export/status/translate agree while the text ships; +`status`/golden untouched (the no-flag path is byte-identical → cleanSources join == `unit.sourceText()`). +**Tests:** dropped-member → edits the clean remainder + ships it flagged (export agrees); all-members-flagged → +ships "". + +### 3.1 Verification-workflow findings closed (all MINOR/NIT telemetry — no bytes/money/resume impact) + +The 3-lens verification of c-lite found the core sound (no CRITICAL/MAJOR) and four consistency items, all fixed: + +1. **quality.go echo/KPI (MINOR — the one un-aligned read-model, an acceptance metric):** a recovered unit's edit + row is now DispOK, so `EchoRate`/`CosmeticStripRate` stopped counting its (real, dropped) draft echo. **Fixed:** + quality re-derives the drop like export/status and counts the dropped member's reason toward the rates. The + structural KPI intentionally measures the shipped remainder (accurate). *Owner nuance:* this preserves the + pre-c-lite semantics ("echo anywhere, incl. dropped members"); if you'd rather `EchoRate` mean "echo in shipped + text only", say so and it's a one-line revert of this preservation. +2. **status.go flag_reason first-vs-last (NIT):** `resolveChunkState` overwrote `reason` on every flagged row + (last-wins), contradicting its own "first (only)" comment and diverging from translate/export on a multi-flag + unit. **Fixed:** first-wins guard (aligns all read-models; also closes the pre-existing plain-multi-member case). +3. **export --pairs source (NIT):** `--pairs` source was the full unit source while the target is the clean-members + edit, mis-pairing the DC1/DC2 FP-measure. **Fixed:** for a dropped-member unit the `--pairs` source is + reconstructed from the clean members (byte-for-byte as `runEditUnit`'s `cleanSources`). +4. **export Detail (NIT):** the member-drop override flipped `FlagReason` but kept the OK edit's blank `Detail`. + **Fixed:** propagate the dropped member's `Detail` alongside its reason. + +--- + +## 4. Owner findings R1-FL-A/B/C — confirmed, DEFERRED to the pere-run mining batch + +All three reproduce exactly and are gated behind LIVE mining (a langpack + a contrast artifact — stand-only). Per +owner: apply in the pere-run-prep batch together, NOT as a micro-commit. + +- **R1-FL-A** (`main.go:34-44`): `exitCode` maps only `*CompletedWithFlags`→2; a `*WaveSignatureStop`→**1** (== a + crash). The `translate()` path returns it raw with no `errors.As` arm; the `waverun.go:72-77` comment over-claims + CLI wiring. Human sees the stderr text; automation can't distinguish a sign-boundary stop from an infra crash. + *Fix shape:* an `errors.As(&sigStop)` arm → a distinct exit code. +- **R1-FL-B** (`mining.go:56`, `miner_emit.go`): the mining STOP clears only on an EMPTY delta and there is no + reject/ignore list — a term the owner declines to sign is re-mined → STOP forever (livelock). The only mechanical + escape (undocumented, semantically wrong) is a `status:auto` no-dst seed row. Ties to the deferred **W1.5-UX** + design (a reject/suppression set). +- **R1-FL-C**: `x/text/norm` carries its own Unicode revision, not folded into Ш-2 (only `unicode.Version` is) — a + real tripwire before an `x/text` bump. + +--- + +## 5. Reviewed — not bugs (NITs, for awareness) + +- `jobs.status` thrash under the shared chapter×stage job row — advisory, nothing gates on it. +- Read-model fields say `chunk`/`total_chunks` but now carry UNIT counts — naming/contract debt for JSON + consumers (counts are internally correct). +- `escMu` held across the escalation LLM call — throughput smell (rare, opt-in escalations), no correctness impact. +- `minedToCandidates` is dead in the live path (superseded by file-based `loadMinedDelta`) — cleanliness. +- The real HTTP adapter's concurrency is unexercised by the fake-client tests — worth one live parallel smoke + (standard `http.Client` is safe by design). + +--- + +## 6. Change set (working tree, UNCOMMITTED — orchestrator lands) + +LIVE wave-driver correctness (NOT mining-gated) — land these: +- `waverun.go`: `runWave` parent-cancellation → error; `runEditUnit` c-lite (edit the clean remainder). +- `export.go`: member-drop re-derivation + Detail + `--pairs` clean-source alignment. +- `status.go`: `resolveChunkState` first-wins guard. +- `quality.go`: echo/strip preservation for a recovered unit. +- `waverun_test.go`: `TestRunWaveSurfacesParentCancellation`, updated `TestWaveEditUnitFlaggedMemberDraft` + (c-lite + export + quality assertions), `TestWaveEditUnitAllMembersFlagged`. + +Evidence: `go build/vet` clean; full `go test -race ./...` clean (~31s); golden byte-stable (no-flag path +identical); gofmt clean. Nothing committed (orchestrator lands per the standing workflow). + +## 7. Open for the owner + +- **Echo-metric semantics** (finding 3.1-1): kept as "echo anywhere incl. dropped members" (non-regression). Switch + to "shipped-text only" if preferred. +- **R1-FL-A/B/C** land in the pere-run mining batch; R1-FL-B needs the W1.5-UX reject-set design. +- Landing order: the two LIVE fixes (§6) can land before the mining batch.