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)
This commit is contained in:
parent
1db62d41a3
commit
488c8d4368
6 changed files with 352 additions and 28 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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++
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
154
docs/archive/reports/R1_POST_LANDING_REVIEW_2026-07-19.md
Normal file
154
docs/archive/reports/R1_POST_LANDING_REVIEW_2026-07-19.md
Normal file
|
|
@ -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.
|
||||
Loading…
Add table
Reference in a new issue