package pipeline import ( "context" "testing" "textmachine/backend/internal/store" "textmachine/backend/internal/terminology" ) // TestBankUnionRecoversASupersededSampling is the aggregation contract end to end. The per-chunk // telemetry row is keyed (book, chapter, chunk) and upserted, so a chunk drafted a second time OVERWRITES // the proposals of the first — and that channel proposes a substantially different set on every sampling, // so the overwrite loses real coverage. The checkpoints keep every answer, so the union must hold both. // // The second answer is written through the ordinary money path rather than simulated in the fold, so the // test would catch a query that misses re-drafted attempts as surely as a fold that drops them. func TestBankUnionRecoversASupersededSampling(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { if isEditBody(body) { return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" } return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop" }) defer srv.Close() r := newRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{})) defer r.Close() if _, err := r.TranslateBook(context.Background()); err != nil { t.Fatal(err) } before, _, err := r.bankObservedForBook() if err != nil { t.Fatal(err) } if len(before) == 0 { t.Fatal("setup: the first sampling must have produced proposals") } if got := keysOf(before); contains(got, "花家") { t.Fatalf("setup: 花家 must not be proposed yet, got %v", got) } // Every proposal of this run came from one chunk, so the union must not have inflated any count. for _, o := range before { for _, p := range o.Proposals { if p.Chunks != 1 { t.Fatalf("a single sampling must count once per chunk, %s→%s has %d", o.Key, p.Dst, p.Chunks) } } } // A SECOND answer for the same chunk, as a re-purchase produces: the telemetry row still holds the // first one, the checkpoint store holds both. snap, _, err := r.snapshotIDForWave(waveDraft) if err != nil { t.Fatal(err) } job, err := r.Store.EnsureJob(r.Book.BookID, 1, "draft", snap) if err != nil { t.Fatal(err) } res, verdict, err := r.Store.Reserve(r.Book.BookID, 0.01, store.Ceilings{BookUSD: 100, DayUSD: 100}) if err != nil || verdict != store.ReserveOK { t.Fatalf("reserve: %v %v", verdict, err) } second := "Фан Юань пришёл к горе Цинмао.\n" + bankSeparator + "\n花家\tДом Хуа\tname\n" if err := r.Store.SettleWithCheckpoint(res, 0.01, store.Checkpoint{ RequestHash: "second-sampling", JobID: job.ID, ChunkIdx: 0, Attempt: 1, Stage: "draft", Role: roleTranslator, ModelRequested: "fake-model", ModelActual: "fake-model", ResponseText: second, UsageJSON: "{}", CostUSD: 0.01, FinishReason: "stop", }); err != nil { t.Fatal(err) } after, _, err := r.bankObservedForBook() if err != nil { t.Fatal(err) } if !contains(keysOf(after), "花家") { t.Fatalf("the union must recover the superseded sampling, got %v", keysOf(after)) } if !contains(keysOf(after), "方源") { t.Fatalf("the union must keep the surviving sampling too, got %v", keysOf(after)) } // The re-drafted chunk still votes once per rendering: two answers from ONE chunk are one chunk. for _, o := range after { for _, p := range o.Proposals { if p.Chunks != 1 { t.Fatalf("a re-drafted chunk must not vote twice, %s→%s has %d", o.Key, p.Dst, p.Chunks) } } } } func keysOf(obs []terminology.Observed) []string { out := make([]string, 0, len(obs)) for _, o := range obs { out = append(out, o.Key) } return out } func contains(ss []string, want string) bool { for _, s := range ss { if s == want { return true } } return false }