textmachine/backend/internal/pipeline/bankunion_test.go

110 lines
3.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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",
}, nil); 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
}