textmachine/backend/internal/pipeline/minirun_fixes_test.go

231 lines
9.2 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"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/store"
"textmachine/backend/internal/terminology"
"textmachine/backend/internal/text"
)
// minirun_fixes_test.go: the two seams the 25.07 mini-run proved were producing a fact and losing it.
// Both are driven end-to-end through the real runner (norm 11) — a unit test on the parser would have
// passed happily before either fix, because neither defect was in the parsing.
// --- Д1: an echo the escalation recovered must still be visible as an echo ---------------------
// echoThenFixProvider makes the PRIMARY draft echo the CJK source (the D18 mine) and the escalation hop
// answer properly — the exact shape the mini-run hit on ch3/chunk0.
func echoThenFixProvider(echoText string) func(body string) (string, string) {
return func(body string) (string, string) {
switch {
case isEditBody(body):
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
case strings.Contains(body, "fake-fallback"):
return "ПЕРЕВОД С ФОЛБЭКА", "stop"
default:
return echoText, "stop"
}
}
}
// TestRecoveredEchoStaysVisibleInTheQualityReport is the headline of Д1: before the fix the metric read
// the row's VERDICT column, so a recovered echo left the row `ok` with an empty flag_reason and the
// report said echo_draft=0.0% on a run whose translator had echoed. The rate watches the TRANSLATOR
// (the D18 mine), not our success at papering over it.
func TestRecoveredEchoStaysVisibleInTheQualityReport(t *testing.T) {
rec := &reqRec{}
// The echo must be CJK-dense enough for the classifier to call it an artifact, and the fixture
// source is ja — so echo the source back, which is precisely what the mine does.
srv := newJSONProvider(rec, echoThenFixProvider(strings.Repeat(suzukiSource, 3)))
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
source: suzukiSource, glossarySeed: suzukiSeed, regenerate: 0,
})
// The fixture pipeline has no escalate_to; add one so the hop can recover the chunk.
addDraftEscalation(t, bookPath)
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil {
t.Fatal(err)
}
cs, err := r.Store.GetChunkStatus("test-book", 1, 0, "draft")
if err != nil || cs == nil {
t.Fatalf("draft row: %v %v", cs, err)
}
if cs.Disposition != string(DispOK) {
t.Fatalf("setup: the hop must have recovered the chunk, got %s/%s", cs.Disposition, cs.FlagReason)
}
if cs.FirstFlagReason != string(FlagCJKArtifact) {
t.Fatalf("the recovered row must remember WHAT the primary attempt did, got first_flag_reason=%q", cs.FirstFlagReason)
}
q, err := r.QualityReport()
if err != nil {
t.Fatal(err)
}
if q.EchoDraftChunks != 1 {
t.Errorf("echo_draft_chunks = %d, want 1 — the translator echoed, whatever we did about it", q.EchoDraftChunks)
}
if q.EchoDraftRecovered != 1 {
t.Errorf("echo_draft_recovered = %d, want 1 — the split is what keeps the number from reading as shipped breakage", q.EchoDraftRecovered)
}
if q.EchoDraftRate == 0 {
t.Error("echo_draft_rate must be non-zero when an echo happened")
}
}
// TestUnrecoveredFlagIsNotDoubleCounted guards the other direction: a row whose verdict IS the echo
// must not also carry it in first_flag_reason, or every genuinely-flagged chunk counts twice.
func TestUnrecoveredFlagIsNotDoubleCounted(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return strings.Repeat(suzukiSource, 3), "stop" // echoes, and no hop is configured to fix it
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: suzukiSource, glossarySeed: suzukiSeed, regenerate: 0})
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil {
t.Fatal(err)
}
cs, err := r.Store.GetChunkStatus("test-book", 1, 0, "draft")
if err != nil || cs == nil {
t.Fatalf("draft row: %v %v", cs, err)
}
if cs.FlagReason != string(FlagCJKArtifact) {
t.Fatalf("setup: the row must stay flagged, got %q", cs.FlagReason)
}
if cs.FirstFlagReason != "" {
t.Errorf("a row whose VERDICT is the echo must not repeat it in first_flag_reason (double count), got %q", cs.FirstFlagReason)
}
q, err := r.QualityReport()
if err != nil {
t.Fatal(err)
}
if q.EchoDraftChunks != 1 {
t.Errorf("echo_draft_chunks = %d, want 1 (counted ONCE)", q.EchoDraftChunks)
}
if q.EchoDraftRecovered != 0 {
t.Errorf("echo_draft_recovered = %d, want 0 — nothing recovered it", q.EchoDraftRecovered)
}
}
// --- Д4: the WHAT the banknote delivered must reach the owner's signature map -------------------
// TestBanknoteProposalsArePersistedAndJoined is the D39.36 fix end to end: the translator proposes a
// rendering, the parser accepts it, and it must survive to the row the mining stop reads. Before the
// fix the parse result went into `_` and the owner met the stop holding bare source terms.
func TestBanknoteProposalsArePersistedAndJoined(t *testing.T) {
rec := &reqRec{}
// A draft carrying a banknote block: two proposals, one of them repeated with a different rendering
// so the vote/alternatives path is exercised too.
draftWithBank := "ПЕРЕВОД ЧАНКА.\n" + bankSeparator + "\n" +
"鈴木\tСудзуки\tname\n" +
"図書館\tбиблиотека\tplace\n"
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return draftWithBank, "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
source: suzukiSource, glossarySeed: suzukiSeed, regenerate: 0, banknote: true,
})
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil {
t.Fatal(err)
}
rs, err := r.Store.GetRetrievalState("test-book", 1, 0)
if err != nil || rs == nil {
t.Fatalf("retrieval_state: %v %v", rs, err)
}
if rs.NBanknoteLines != 2 {
t.Fatalf("setup: the parser must have accepted 2 lines, got %d", rs.NBanknoteLines)
}
if rs.BanknoteDetail == "" {
t.Fatal("the PARSED entries must be durable — counters alone are what D39.36 found useless")
}
var props []bankProposal
if err := json.Unmarshal([]byte(rs.BanknoteDetail), &props); err != nil {
t.Fatalf("banknote_detail must be readable JSON: %v (%s)", err, rs.BanknoteDetail)
}
if len(props) != 2 {
t.Fatalf("want 2 stored proposals, got %d: %s", len(props), rs.BanknoteDetail)
}
// The signature-map join reads the MERGED candidates (fix-pack §G3), so the assertion follows the same
// path production takes: a proposal must land on the miner's normalized surface after the merge, not
// only in the raw draft-side view.
observed := bankObservedByKey([]store.RetrievalState{*rs}, nil)
byKey := proposalsFromCandidates(terminology.Merge(nil, observed, text.NormalizeTargetForm))
if got := byKey[props[0].SrcKey]; len(got) == 0 || got[0].Dst != props[0].Dst {
t.Fatalf("the fold must key proposals by the miner's normalized surface, got %+v", byKey)
}
}
// TestBanknoteOffLeavesTheDetailEmpty pins that a channel-off book's row is unchanged — the fix must be
// invisible to every book that does not use the channel.
func TestBanknoteOffLeavesTheDetailEmpty(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: suzukiSource, glossarySeed: suzukiSeed, regenerate: 0})
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil {
t.Fatal(err)
}
rs, err := r.Store.GetRetrievalState("test-book", 1, 0)
if err != nil || rs == nil {
t.Fatalf("retrieval_state: %v %v", rs, err)
}
if rs.BanknoteDetail != "" {
t.Errorf("a banknote-off book must store no proposals, got %q", rs.BanknoteDetail)
}
}
// addDraftEscalation gives the fixture project a single-hop fallback on the draft stage: the hop model
// in models.yaml, the stage's escalate_to, and the budget that arms it (escalation is inert at budget 0
// — the shipping default).
func addDraftEscalation(t *testing.T, bookPath string) {
t.Helper()
dir := filepath.Dir(bookPath)
modelsPath := filepath.Join(dir, "models.yaml")
models, err := os.ReadFile(modelsPath)
if err != nil {
t.Fatal(err)
}
writeFile(t, modelsPath, string(models)+`
fake-fallback:
provider: fake
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
`)
pipePath := filepath.Join(dir, "pipeline.yaml")
pipe, err := os.ReadFile(pipePath)
if err != nil {
t.Fatal(err)
}
withHop := strings.Replace(string(pipe),
`reasoning: "off" }`,
`reasoning: "off", escalate_to: fake-fallback }`, 1)
if withHop == string(pipe) {
t.Fatal("setup: the draft stage line was not found in the fixture pipeline")
}
writeFile(t, pipePath, withHop+"\nescalation: { budget_usd: 0.5 }\n")
}