158 lines
7 KiB
Go
158 lines
7 KiB
Go
package pipeline
|
||
|
||
import (
|
||
"context"
|
||
"os"
|
||
"path/filepath"
|
||
"strings"
|
||
"testing"
|
||
|
||
"textmachine/backend/internal/membank"
|
||
"textmachine/backend/internal/obs"
|
||
)
|
||
|
||
// snapshotdiff_test.go: the re-pin guard must name the field it COMPARED and nothing else.
|
||
//
|
||
// ⚠ ON THE FORM OF THESE ASSERTIONS (D39.171). The message is asserted through the ERROR THE GUARD
|
||
// RETURNS, never by grepping a shared log buffer: a substring assertion over a buffer every test writes
|
||
// into stays green when the sentence it claims to pin has moved elsewhere or disappeared. Here the string
|
||
// under test is the return value of the call under test, so a changed message cannot pass unnoticed — and
|
||
// the negative assertion below (the retired false sentence must be ABSENT) is what makes it a landing
|
||
// rather than a decoration.
|
||
|
||
// TestTheMovedFieldIsNamedAndTheOldLieIsGone is the A4 landing on the ordinary live shape: the BANK moved
|
||
// and nothing else did. This is the cold run of 31.08 reproduced at $0 — there the two edit-wave payloads
|
||
// differed in memory_version alone while prompt_sha256, model, temperature and reasoning were byte-equal,
|
||
// and the guard told its reader «the config/prompts changed».
|
||
//
|
||
// Mutation this catches: restore the old sentence (or hard-code any cause) and BOTH assertions fire — the
|
||
// positive one because memory_version is no longer named, the negative one because the retired claim is back.
|
||
func TestTheMovedFieldIsNamedAndTheOldLieIsGone(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := volumeBook(t, srv.URL, 2)
|
||
dir := filepath.Dir(bookPath)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r1.Close()
|
||
|
||
// Sign a term that occurs in the source: the bank moves, and with it the edit-wave snapshot — and
|
||
// NOTHING else about the configuration is touched, which is the whole premise.
|
||
doc := decisionsDocFor(t, dir, "test-book", membank.Decision{
|
||
Action: membank.ActionApprove, Src: "静か", Dst: "тихий"})
|
||
if _, err := ApplyBankDecisions(ctx, bookPath, doc, false); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
r2 := newRunner(t, bookPath)
|
||
defer r2.Close()
|
||
// The re-payment consent gate sits BEFORE the snapshot guard and would stop the run with its own
|
||
// message; consenting is not what is under test here.
|
||
r2.AcceptRebill = RebillConsent{Given: true}
|
||
_, err := r2.TranslateBook(ctx) // no --resnapshot: the guard must stop the run
|
||
if err == nil {
|
||
t.Fatal("a moved snapshot without --resnapshot must stop the run")
|
||
}
|
||
msg := err.Error()
|
||
if !strings.Contains(msg, memoryVersionField) {
|
||
t.Fatalf("the guard must NAME the field that actually moved (%s), got:\n%s", memoryVersionField, msg)
|
||
}
|
||
if !strings.Contains(msg, "that is the BANK") {
|
||
t.Fatalf("a bank-only move must be called what it is, so the operator does not go looking for a "+
|
||
"config edit; got:\n%s", msg)
|
||
}
|
||
if strings.Contains(msg, "the config/prompts changed") {
|
||
t.Fatalf("the retired claim asserts a cause the engine never compared — it must not come back:\n%s", msg)
|
||
}
|
||
// And it must not name axes that did NOT move: a message that lists everything names nothing.
|
||
for _, absent := range []string{"prompt_sha256", "temperature", "model"} {
|
||
if strings.Contains(msg, absent) {
|
||
t.Fatalf("%q did not move on this book, so the guard must not name it:\n%s", absent, msg)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestAPromptEditIsNamedAsAPromptEdit is the same guard on the case its retired sentence was ACCIDENTALLY
|
||
// right about — and it is here because a message that says "the bank" for every move would be the same
|
||
// defect with a different constant. The prompt file is edited between runs; nothing else changes.
|
||
func TestAPromptEditIsNamedAsAPromptEdit(t *testing.T) {
|
||
rec := &reqRec{}
|
||
srv := newJSONProvider(rec, draftEdit)
|
||
defer srv.Close()
|
||
bookPath := volumeBook(t, srv.URL, 2)
|
||
dir := filepath.Dir(bookPath)
|
||
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
|
||
|
||
r1 := newRunner(t, bookPath)
|
||
if _, err := r1.TranslateBook(ctx); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
r1.Close()
|
||
|
||
if err := os.WriteFile(filepath.Join(dir, "prompts", "editor.md"),
|
||
[]byte("Редактируй перевод ИНАЧЕ.\n---USER---\nИсходник: {{text}}\nЧерновик перевода для редактуры: {{draft}}"), 0o644); err != nil {
|
||
t.Fatal(err)
|
||
}
|
||
|
||
r2 := newRunner(t, bookPath)
|
||
defer r2.Close()
|
||
r2.AcceptRebill = RebillConsent{Given: true} // as above: the consent gate is not the subject
|
||
_, err := r2.TranslateBook(ctx)
|
||
if err == nil {
|
||
t.Fatal("an edited prompt moves the snapshot, so the run must stop without --resnapshot")
|
||
}
|
||
msg := err.Error()
|
||
if !strings.Contains(msg, "prompt_sha256") {
|
||
t.Fatalf("an edited prompt must be named as the prompt hash, not as something else:\n%s", msg)
|
||
}
|
||
if strings.Contains(msg, "that is the BANK") {
|
||
t.Fatalf("this move is NOT a bank move, and saying so would be the retired defect with a new "+
|
||
"constant:\n%s", msg)
|
||
}
|
||
}
|
||
|
||
// TestAnUncomparableMoveSaysSoInsteadOfGuessing is §2.2 in its pure form: with no stored payload there is
|
||
// nothing to compare, and the guard must say that rather than fall back to a plausible cause.
|
||
func TestAnUncomparableMoveSaysSoInsteadOfGuessing(t *testing.T) {
|
||
got := describeSnapshotMove(snapshotFieldDiff("", `{"a":1}`))
|
||
if !strings.Contains(got, "cannot be named") || !strings.Contains(got, "do not assume") {
|
||
t.Fatalf("with nothing to compare, the guard must decline to name a cause: %q", got)
|
||
}
|
||
if strings.Contains(got, "config") && !strings.Contains(got, "do not assume a config") {
|
||
t.Fatalf("a declined comparison must not smuggle a cause back in: %q", got)
|
||
}
|
||
}
|
||
|
||
// TestTheDiffIsDeterministicAndSorted pins the property the orchestrator asked for by name: the message
|
||
// must not change between two runs over the same two payloads. Go map iteration is randomised per range,
|
||
// so an unsorted walk fails this within a handful of iterations.
|
||
func TestTheDiffIsDeterministicAndSorted(t *testing.T) {
|
||
was := `{"z":1,"a":1,"m":{"q":1,"b":1},"stages":[{"model":"x","temperature":0.3}]}`
|
||
now := `{"z":2,"a":2,"m":{"q":2,"b":2},"stages":[{"model":"y","temperature":0.3}]}`
|
||
first, ok := snapshotFieldDiff(was, now)
|
||
if !ok {
|
||
t.Fatal("both payloads are valid JSON objects, so the comparison must succeed")
|
||
}
|
||
want := []string{"a", "m.b", "m.q", "stages[0].model", "z"}
|
||
if strings.Join(first, "|") != strings.Join(want, "|") {
|
||
t.Fatalf("paths must be sorted and dotted; got %v want %v", first, want)
|
||
}
|
||
for i := 0; i < 50; i++ {
|
||
again, _ := snapshotFieldDiff(was, now)
|
||
if strings.Join(again, "|") != strings.Join(first, "|") {
|
||
t.Fatalf("the diff is not deterministic: %v then %v — an operator comparing two logs of the "+
|
||
"same move would be told two different stories", first, again)
|
||
}
|
||
}
|
||
// temperature did not move and must not be reported.
|
||
for _, p := range first {
|
||
if strings.Contains(p, "temperature") {
|
||
t.Fatalf("an unchanged field must not appear in the diff: %v", first)
|
||
}
|
||
}
|
||
}
|