textmachine/backend/internal/pipeline/snapshotdiff_test.go

158 lines
7 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 (
"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)
}
}
}