136 lines
7 KiB
Go
136 lines
7 KiB
Go
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
|
||
}
|