textmachine/backend/internal/pipeline/offtargetflag_test.go

136 lines
7 KiB
Go
Raw Permalink 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/checks"
"textmachine/backend/internal/config"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/langscreen"
"textmachine/backend/internal/llm"
)
// offtargetflag_test.go: the LIVE delivery of backlog row 46. The screen module is pure and pinned by its
// own package; the corpus test pins the verdict OFFLINE, over rune accounting. Neither makes `classify()`
// return the flag, and neither makes the runner hand it the data — so the whole path from «a completion
// came back in the wrong language» to «the run flags it and escalates it» existed only as code.
//
// ⚠ THIS IS THE D39.187 SHAPE AND IT WAS FOUND BY PLANTING, not by reading: three separate mutations —
// screening against nil scripts, dropping TargetScripts at the call site, and removing the flag from
// escalatable() — each left the whole battery green.
// TestAnOffTargetCompletionIsFlaggedAndEscalated is the verdict itself, through the real classifier.
func TestAnOffTargetCompletionIsFlaggedAndEscalated(t *testing.T) {
// A fluent English completion, past the evidence floor — the live class this flag exists for (two such
// drafts shipped as `ok` on the stand, 4690 and 4811 letters).
english := strings.Repeat("The librarian walked the long corridor and remembered the lecture. ", 8)
if n := countLetters(english); n < langscreen.MinLetters {
t.Fatalf("premise broken: the fixture must clear the evidence floor, got %d letters", n)
}
in := classifyInput{
Output: english, Finish: llm.FinishStop, TargetLang: "ru",
SourceScripts: lang.LangScripts("zh"), TargetScripts: lang.LangScripts("ru"),
}
got := classify(in)
if got.Reason != FlagOffTargetLang {
t.Fatalf("an English completion for a →ru book must flag as off-target, got %q (%s)", got.Reason, got.Detail)
}
// The DETAIL is what an operator acts on: a bare flag with no score is the class of report this project
// keeps replacing.
if !strings.Contains(got.Detail, "target's script") {
t.Errorf("the flag must say what it measured: %q", got.Detail)
}
// ⛔ AND IT MUST BE ESCALATABLE — a direct requirement of D39.93 п.2, and the whole reason the design
// was NOT allowed to reuse FlagCJKArtifact: a named flag outside the whitelist is a chunk silently LOST
// instead of re-attacked on another model.
if !got.Reason.escalatable() {
t.Fatal("FlagOffTargetLang must be in escalatable(): a deterministic content failure another model might fix (D39.93 п.2)")
}
if got.Reason.retryable() {
t.Fatal("re-asking the SAME model buys the same answer; the flag must not be retryable")
}
// The echo rule still owns its own class: a source echo is named an echo, not «off target». The ORDER
// is the diagnosis, and a screen that swallowed the echo class would make every echo unattributable.
echoed := strings.Repeat("方源看着蛊,那是一只月光蛊。", 30)
if r := classify(classifyInput{Output: echoed, Finish: llm.FinishStop, TargetLang: "ru",
SourceScripts: lang.LangScripts("zh"), TargetScripts: lang.LangScripts("ru")}); r.Reason != FlagCJKArtifact {
t.Fatalf("a source echo must still be named an echo, got %q", r.Reason)
}
}
// TestTheScreenIsInertWithoutTargetData is the layer-7 discipline ON THE LIVE PATH: a pair whose target
// nobody has authored a script row for must be INERT, not condemned. It is asserted here as well as in the
// module because this is where the data is handed over, and handing over nothing is a live possibility.
func TestTheScreenIsInertWithoutTargetData(t *testing.T) {
english := strings.Repeat("The librarian walked the long corridor and remembered the lecture. ", 8)
got := classify(classifyInput{Output: english, Finish: llm.FinishStop, TargetLang: "xx",
SourceScripts: lang.LangScripts("zh"), TargetScripts: lang.LangScripts("xx")})
if got.Reason != reasonOK {
t.Fatalf("a target that declares no script must not be judged, got %q", got.Reason)
}
}
// TestTheRunnerHandsTheScreenItsData closes the seam between the two tests above: the classifier can be
// perfectly correct and never receive the target's scripts. classifyOutput is the ONE call site the run
// uses (live and replay both), so the assertion is made through it rather than through classify.
func TestTheRunnerHandsTheScreenItsData(t *testing.T) {
r := &Runner{Book: &config.Book{TargetLang: "ru", SourceLang: "zh"}, Pipeline: &config.Pipeline{}}
r.checkers = checks.CompileCheckers(nil, lang.TargetChecksFor("ru"))
r.checkers.SetSourceScripts(lang.LangScripts("zh"))
english := strings.Repeat("The librarian walked the long corridor and remembered the lecture. ", 8)
cls, _ := r.classifyOutput(roleTranslator, "方源看着蛊。", english, llm.FinishStop, true)
if cls.Reason != FlagOffTargetLang {
t.Fatalf("the runner must hand the screen the target's scripts; got %q (%s)", cls.Reason, cls.Detail)
}
// ⚠ AND THE BANK ROLES MUST STILL BE EXEMPT, through the same call. A term table's letters are engine
// identifiers and source terms, so BOTH rules would fire on every healthy batch — the echo rule already
// did, on the classifier, for as long as no shipping config ran it (§Б-105).
table := strings.Repeat("元石\tterm\tnone\n方源\tname\tmale\n", 40)
for _, role := range []string{roleTerminologist, roleClassifier} {
if cls, _ := r.classifyOutput(role, "方源看着蛊。", table, llm.FinishStop, false); cls.Reason != reasonOK {
t.Errorf("%s answers a term table and must be exempt from both rules, got %q (%s)", role, cls.Reason, cls.Detail)
}
}
// The exemption is the role SET, not one role: the regression that leaves it at the terminologist alone
// is exactly the state this pack found and fixed.
if cls, _ := r.classifyOutput(roleClassifier, "方源看着蛊。", table, llm.FinishStop, false); cls.Reason != reasonOK {
t.Errorf("the classifier's reply has the same shape as the terminologist's: %q", cls.Reason)
}
}
// TestTheOffTargetThresholdIsUSED, not merely declared: a completion BETWEEN the two bounds decides which
// side of the floor the rule reads, and without such a case the constant could be anything.
func TestTheOffTargetThresholdIsUsed(t *testing.T) {
for _, tc := range []struct {
name string
cyr, lat int
want FlagReason
}{
// 60% of the letters in the target's script — above the 0.50 floor, so the run keeps it.
{"mostly target", 600, 400, reasonOK},
// 40% — below the floor.
{"mostly foreign", 400, 600, FlagOffTargetLang},
} {
t.Run(tc.name, func(t *testing.T) {
out := strings.Repeat("а", tc.cyr) + strings.Repeat("a", tc.lat)
got := classify(classifyInput{Output: out, Finish: llm.FinishStop, TargetLang: "ru",
SourceScripts: lang.LangScripts("zh"), TargetScripts: lang.LangScripts("ru")})
if got.Reason != tc.want {
t.Fatalf("share %.2f → %q, want %q (%s)", float64(tc.cyr)/float64(tc.cyr+tc.lat), got.Reason, tc.want, got.Detail)
}
})
}
}
func countLetters(s string) int {
n := 0
for _, r := range s {
if r >= 'A' {
n++
}
}
return n
}