textmachine/backend/internal/pipeline/disposition_test.go

138 lines
6.8 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 (
"strings"
"testing"
"textmachine/backend/internal/llm"
)
// classify: ORDER is the contract (D2.1/D2.4) — refusal/echo/deterministic finish
// signals are decided BEFORE length/empty, so a truncated refusal is not turned
// into a paid length retry.
func TestClassifyOrder(t *testing.T) {
const ru = "ru"
cases := []struct {
name string
in classifyInput
wantReason FlagReason
}{
{"clean stop", classifyInput{Source: "静かな朝。", Output: "Тихое утро.", Finish: llm.FinishStop, TargetLang: ru}, reasonOK},
{"empty at stop", classifyInput{Source: "静かな朝。", Output: " ", Finish: llm.FinishStop, TargetLang: ru}, FlagEmpty},
{"empty at length (thinking ate budget)", classifyInput{Source: "静かな朝。", Output: "", Finish: llm.FinishLength, TargetLang: ru}, FlagEmpty},
{"genuine length cut", classifyInput{Source: strings.Repeat("текст ", 50), Output: "Начало предложения обрывается на", Finish: llm.FinishLength, TargetLang: ru}, FlagLength},
{"length but repetition loop", classifyInput{Source: "x", Output: strings.Repeat("ло ", 60), Finish: llm.FinishLength, TargetLang: ru}, FlagLoopDegenerate},
{"truncated soft refusal (NOT length)", classifyInput{Source: "静かな朝、彼女は本を開いた。長い一日の始まりだった。", Output: "Я не могу помочь с этим запросом.", Finish: llm.FinishLength, TargetLang: ru}, FlagSoftRefusal},
{"english refusal short", classifyInput{Source: "長い長い長い長い文章です。", Output: "I'm sorry, but I cannot assist with that.", Finish: llm.FinishStop, TargetLang: ru}, FlagSoftRefusal},
{"cjk echo (untranslated)", classifyInput{Source: "彼は言った。", Output: "彼は言った。とても静かな朝だった。", Finish: llm.FinishStop, TargetLang: ru}, FlagCJKArtifact},
{"provider content_filter finish", classifyInput{Source: "x", Output: "partial", Finish: llm.FinishContentFilter, TargetLang: ru}, FlagContentFilter},
{"provider refusal finish", classifyInput{Source: "x", Output: "", Finish: llm.FinishRefusal, TargetLang: ru}, FlagHardRefusal},
{"resumed decode checkpoint", classifyInput{Source: "x", Output: "", Finish: decodeErrorFinish, TargetLang: ru}, FlagDecodeError},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
got := classify(tc.in)
if got.Reason != tc.wantReason {
t.Fatalf("classify reason = %q, want %q (detail %q)", got.Reason, tc.wantReason, got.Detail)
}
if (got.Reason == reasonOK) != got.ok() {
t.Fatalf("ok() inconsistent for reason %q", got.Reason)
}
})
}
}
// A refusal that is NOT anomalously short (a long faithful translation that
// merely quotes a refusal-like phrase as dialogue) must NOT flag.
func TestClassifyLongOutputWithRefusalPhraseIsOK(t *testing.T) {
src := strings.Repeat("短", 100) // 100 CJK runes → threshold max(400, 50) = 400
out := "«Я не могу помочь тебе», — тихо сказал он, отводя взгляд. " + strings.Repeat("Слова растворялись в утреннем тумане, и никто их не услышал. ", 12)
got := classify(classifyInput{Source: src, Output: out, Finish: llm.FinishStop, TargetLang: "ru"})
if !got.ok() {
t.Fatalf("a long translation quoting a refusal phrase must be ok, got %q", got.Reason)
}
}
// Determinism: identical input yields the identical verdict (pure function).
func TestClassifyDeterministic(t *testing.T) {
in := classifyInput{Source: "彼は静かに歩いた。", Output: strings.Repeat("эхо ", 40), Finish: llm.FinishLength, TargetLang: "ru"}
a, b := classify(in), classify(in)
if a != b {
t.Fatalf("classify is not deterministic: %+v vs %+v", a, b)
}
}
func TestRetryableSubset(t *testing.T) {
retry := map[FlagReason]bool{FlagLength: true, FlagEmpty: true}
all := []FlagReason{
reasonOK, FlagLength, FlagEmpty, FlagLoopDegenerate, FlagHardRefusal, FlagSoftRefusal,
FlagContentFilter, FlagCJKArtifact, FlagDecodeError, FlagCoverageFail, FlagExcisionSuspect,
FlagHardBlock, FlagUpstreamNotOK,
}
for _, r := range all {
if got, want := r.retryable(), retry[r]; got != want {
t.Fatalf("%q.retryable() = %v, want %v (only length/empty are retryable)", r, got, want)
}
}
if reasonOK.disposition() != DispOK {
t.Fatal("reasonOK must resolve to DispOK")
}
if FlagLength.disposition() != DispFlagged {
t.Fatal("a flag reason must resolve to DispFlagged")
}
}
// maxTokensForAttempt is pure and doubles per attempt (D2.3), which is what puts
// a DIFFERENT max_tokens (and thus request_hash) on each regeneration.
func TestMaxTokensForAttempt(t *testing.T) {
base := 2048
for attempt, want := range map[int]int{0: base, 1: 2 * base, 2: 4 * base, 3: 8 * base} {
if got := maxTokensForAttempt(base, attempt); got != want {
t.Fatalf("maxTokensForAttempt(%d, %d) = %d, want %d", base, attempt, got, want)
}
}
// Overflow guard: an absurd attempt is clamped, never a negative shift.
if got := maxTokensForAttempt(base, 999); got <= 0 {
t.Fatalf("maxTokensForAttempt overflow guard failed: %d", got)
}
}
func TestDegenerateLoop(t *testing.T) {
if !degenerateLoop(strings.Repeat("одно и то же ", 40)) {
t.Fatal("a repeated phrase must be a degenerate loop")
}
healthy := "Она открыла книгу и начала читать первую главу, где юный герой впервые покидал родную деревню навстречу далёким горам и неизвестной судьбе, полной опасностей."
if degenerateLoop(healthy) {
t.Fatal("healthy prose must NOT be a degenerate loop")
}
if degenerateLoop("короткий обрыв на пол") {
t.Fatal("a short length cut is not enough evidence for a loop")
}
}
func TestIsRefusalShortVsLong(t *testing.T) {
if !isRefusal("Извините, я не могу перевести это.", "abc") {
t.Fatal("a short refusal must be detected")
}
long := strings.Repeat("Это обычный длинный перевод без всякого отказа. ", 30)
if isRefusal(long, "abc") {
t.Fatal("a long non-refusal must not be flagged")
}
}
func TestCJKShareAndTarget(t *testing.T) {
if s := cjkShare("彼は言った"); s < 0.9 {
t.Fatalf("all-CJK text share = %.2f, want ~1", s)
}
if s := cjkShare("Полностью русский текст"); s > 0.01 {
t.Fatalf("russian text share = %.2f, want ~0", s)
}
if !isCJKTarget("ja") || !isCJKTarget("ZH") || isCJKTarget("ru") {
t.Fatal("isCJKTarget classification wrong")
}
// Into a CJK target, CJK output is expected — no echo flag.
got := classify(classifyInput{Source: "Утро.", Output: "静かな朝。", Finish: llm.FinishStop, TargetLang: "ja"})
if got.Reason == FlagCJKArtifact {
t.Fatal("CJK output must not be an echo artifact when the target IS CJK")
}
}