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") } }