textmachine/backend/internal/membank/decisions_fuzz_test.go

273 lines
14 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 membank
import (
"reflect"
"testing"
"textmachine/backend/internal/seed"
"textmachine/backend/internal/store"
)
// decisions_fuzz_test.go: the decision document is the ONE place where bytes produced by another
// process become engine state — that is the whole of D39.156 — so it is fuzzed rather than merely
// exampled, on the same rule the sibling module states for its own decoder.
//
// The oracles are phrased as "if the door said yes, then …", because a REFUSAL is always an acceptable
// answer to arbitrary bytes and only an ACCEPTANCE can be wrong. They are the invariants the rest of
// this door is built on, and each of them is one a table of examples can hold while the property
// underneath is false:
//
// 1. nothing panics, whatever arrives;
// 2. ALL OR NOTHING — if anything was rejected, both documents come back exactly as they went in.
// This is the invariant that protects a user's existing decisions from a half-applied call;
// 3. NOTHING IS SILENTLY DROPPED — every input decision appears exactly once, as accepted or as
// rejected. A decision that vanishes is the worst outcome available: the caller is told the call
// succeeded and the user's correction is simply not there;
// 4. an accepted result is a FIXED POINT — applying the same decisions again changes nothing and says
// `already_applied`. This is the retry contract as a caller experiences it, and it is the one property
// of the four that no single call inside ApplyDecisions already checks. (The oracle here used to be
// "the result loads", which the door had itself just verified two lines earlier — a tautology dressed
// as a check, and it was locked behind the same condition that made it unreachable when it mattered.)
// applyOracles runs one decoded document through the door and asserts the four invariants.
func applyOracles(t *testing.T, in ApplyInput) {
t.Helper()
res := ApplyDecisions(in) // oracle 1: a panic fails the fuzz run by itself
if len(res.Rejected) > 0 {
// Oracle 2. Compared through the canonical rendering, which is what the caller writes.
beforeD, _ := RenderSeedFile(in.Delta)
afterD, _ := RenderSeedFile(res.Delta)
beforeR, _ := RenderRejectFile(in.Rejects)
afterR, _ := RenderRejectFile(res.Rejects)
if string(beforeD) != string(afterD) || string(beforeR) != string(afterR) {
t.Fatalf("a refused call changed a document\ndelta before:\n%s\ndelta after:\n%s\nrejects before:\n%s\nrejects after:\n%s",
beforeD, afterD, beforeR, afterR)
}
if len(res.Accepted) != 0 {
t.Fatalf("a refused call reported %d accepted decisions", len(res.Accepted))
}
}
// Oracle 3. Index -1 is the document-level verdict and is not one of the input decisions.
seen := make([]int, len(in.Decisions))
for _, a := range res.Accepted {
if a.Index < 0 || a.Index >= len(in.Decisions) {
t.Fatalf("accepted decision carries index %d, outside the request of %d", a.Index, len(in.Decisions))
}
seen[a.Index]++
}
for _, r := range res.Rejected {
if r.Index == -1 {
continue
}
if r.Index < 0 || r.Index >= len(in.Decisions) {
t.Fatalf("rejected decision carries index %d, outside the request of %d", r.Index, len(in.Decisions))
}
seen[r.Index]++
}
if len(res.Rejected) == 0 {
// On an accepted call every decision must be accounted for exactly once. On a REFUSED call the
// accepted list is deliberately empty (oracle 2), so the count is only asserted here.
for i, n := range seen {
if n != 1 {
t.Fatalf("decision %d appears %d times in the report — a decision must never be silently dropped", i, n)
}
}
// Oracle 4: the RESULT is a fixed point. Re-applied through the bytes the door would actually
// write — which is the state the next call really starts from, not the in-memory document.
deltaRaw, rejectRaw := res.DeltaBytes, res.RejectBytes
nextDelta, derr := seed.DecodeFile(deltaRaw)
nextRejects, rerr := seed.DecodeRejects(rejectRaw)
if derr != nil || rerr != nil {
t.Fatalf("an accepted result would not read back: delta=%v rejects=%v\n%s\n%s", derr, rerr, deltaRaw, rejectRaw)
}
second := in
second.Delta, second.Rejects = nextDelta, nextRejects
again := ApplyDecisions(second)
if len(again.Rejected) != 0 {
t.Fatalf("re-applying an accepted set was refused — a retry must converge, not flip: %+v", again.Rejected)
}
for _, a := range again.Accepted {
if a.State != StateAlreadyApplied {
t.Fatalf("decision %d reports %q on a retry of a set already applied: %+v", a.Index, a.State, a)
}
}
if again.DeltaTouched || again.RejectsTouched {
t.Fatalf("a retry touched a document: delta=%v rejects=%v", again.DeltaTouched, again.RejectsTouched)
}
}
}
// FuzzDecisionFieldsThroughTheDoor fuzzes the path the door is actually driven through: arbitrary FIELD
// STRINGS of a Decision, folded by ApplyDecisions and written out.
//
// It exists because the sibling round-trip fuzzer has a structural blind spot, and the blocker of this
// pack walked straight through it. That one fuzzes raw YAML, so its corpus consists by construction of
// strings that have ALREADY survived a YAML parse; the door's real input is a JSON document, on which
// YAML imposes nothing at all. No value that breaks the emitter can be reached from that corpus — a
// leading newline in a note is lawful JSON and is not producible by parsing YAML into a plain scalar.
//
// The oracle is the one §4.1 asks for, on BOTH documents: what the engine WRITES, the engine READS BACK,
// and reads back the SAME. Not merely "it parses" — two measured values parsed fine and came back
// changed («\n» as empty, «\n x» as «x»), which is the silent half of the same defect.
func FuzzDecisionFieldsThroughTheDoor(f *testing.F) {
f.Add("方源", "Фан Юань", "\nне термин", "name", "старший", 0, 0)
f.Add("x", "y", "\n", "", "", 1, 2)
f.Add("x", "y", "\n вложенный", "", "", 0, 0)
f.Add("x", "y", "текст\n\n", "place", "", 0, 0)
f.Add(" пробелы ", "\tтаб", "a\nb", "", " sense ", 0, 0)
f.Add("", "", "", "", "", 0, 0)
f.Add("赵大", "Чжао Да", "конфликт с сидом", "name", "", 0, 0)
f.Fuzz(func(t *testing.T, src, dst, note, kind, sense string, since, until int) {
in := ApplyInput{
Seed: []store.GlossaryEntry{{Src: "赵大", Dst: "Чжао Да", Status: "approved", Source: "seed"}},
Decisions: []Decision{
{Action: ActionApprove, Src: src, Sense: sense, Dst: dst, Kind: kind, Note: note,
SinceChapter: since, UntilChapter: until},
{Action: ActionDecline, Src: src + "-alt", Note: note},
},
}
res := ApplyDecisions(in)
if len(res.Rejected) > 0 {
// A refusal is always an acceptable answer to arbitrary input — but it must leave NOTHING
// behind, and it must hand the caller no bytes to write. Asserted here rather than assumed:
// «returns early on a refusal» is how the old oracle 4 became unreachable exactly when it
// mattered, and this is the same shape one function along.
if res.DeltaBytes != nil || res.RejectBytes != nil {
t.Fatalf("a refused call handed the writer bytes: delta=%q rejects=%q", res.DeltaBytes, res.RejectBytes)
}
if len(res.Accepted) != 0 {
t.Fatalf("a refused call reported %d accepted decisions", len(res.Accepted))
}
if !reflect.DeepEqual(res.Delta.Normalize(), in.Delta.Normalize()) ||
!reflect.DeepEqual(res.Rejects.Normalize(), in.Rejects.Normalize()) {
t.Fatalf("a refused call changed a document:\n%+v\n%+v", res.Delta, res.Rejects)
}
return
}
if res.DeltaBytes == nil || res.RejectBytes == nil {
t.Fatalf("an accepted call must hand its caller the bytes it verified: delta=%v rejects=%v",
res.DeltaBytes != nil, res.RejectBytes != nil)
}
backDelta, derr := seed.DecodeFile(res.DeltaBytes)
if derr != nil {
t.Fatalf("the engine would write a delta it cannot read: %v\n%q", derr, res.DeltaBytes)
}
backRejects, rerr := seed.DecodeRejects(res.RejectBytes)
if rerr != nil {
t.Fatalf("the engine would write a reject list it cannot read: %v\n%q", rerr, res.RejectBytes)
}
// …and the SAME document, not merely a parseable one.
if want := res.Delta.Normalize(); !reflect.DeepEqual(backDelta.Normalize(), want) {
t.Fatalf("the delta reads back as another document:\nwrote %q\nheld %+v\nread %+v", res.DeltaBytes, want, backDelta.Normalize())
}
if want := res.Rejects.Normalize(); !reflect.DeepEqual(backRejects.Normalize(), want) {
t.Fatalf("the reject list reads back as another document:\nwrote %q\nheld %+v\nread %+v", res.RejectBytes, want, backRejects.Normalize())
}
// The result loads as a BANK, which is the next thing that happens to it.
if _, err := ParseBankSeed("fuzz", res.DeltaBytes); err != nil {
t.Fatalf("an accepted result would not load into the bank: %v\n%s", err, res.DeltaBytes)
}
})
}
// FuzzDecisionDocument drives the whole door from raw request bytes: decode, then apply against a small
// but non-trivial book — a signed seed with an alias, a ruby reading grafted onto it, a voice profile,
// and a delta that already holds a decision. Those four are exactly the inputs whose interaction the
// review found defects in, so they are the state the fuzzer explores against rather than an empty book.
func FuzzDecisionDocument(f *testing.F) {
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"approve","src":"方源","dst":"Фан Юань"}]}`)
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"decline","src":"花海"}]}`)
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"approve","id":"0000000000000000","dst":"X"}]}`)
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"approve","src":"a","dst":"b"},{"action":"decline","src":"a"}]}`)
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"approve","src":"赵大","dst":"Другое"}]}`)
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"approve","src":"x","sense":"s","since_chapter":-1,"dst":"y"}]}`)
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[]}`)
// The lawful decline of a SEED SURFACE the delta holds a row for — the livelock repair, and the one
// trajectory whose re-send the door used to refuse forever. Oracle 4 below is exactly the property
// it breaks; the corpus simply could not reach the state, because the delta fixture held no row for
// a seed surface. One row (老赵, an alias of the signed 赵大) and this seed put it in reach.
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"decline","src":"老赵"}]}`)
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"decline","src":"老赵"},{"action":"approve","src":"李青","dst":"Ли Цин"}]}`)
f.Add(`not json at all`)
bank := []store.GlossaryEntry{
{Src: "方源", Status: "auto", Source: "mined", Type: "name",
Aliases: []store.GlossaryAlias{{Alias: "方小子", AliasType: "mined"}}},
}
seedRows := []store.GlossaryEntry{
{Src: "赵大", Dst: "Чжао Да", Status: "approved", Source: "seed",
Aliases: []store.GlossaryAlias{{Alias: "老赵"}}},
{Src: "高橋", Dst: "Такахаси", Status: "approved", Source: "seed"},
}
f.Fuzz(func(t *testing.T, raw string) {
doc, err := DecodeDecisions([]byte(raw))
if err != nil {
return // a refusal is always an acceptable answer to arbitrary bytes
}
// Whatever the decoder ACCEPTED must satisfy the envelope contract.
if doc.Version != DecisionsVersion || doc.BookID == "" || len(doc.Decisions) == 0 {
t.Fatalf("the decoder accepted a document that violates its own envelope: %+v", doc)
}
applyOracles(t, ApplyInput{
Bank: bank, Seed: seedRows,
Ruby: []store.RubyReading{{Base: "高橋", Reading: "たかはし", Occurrences: 2, FirstChapter: 1}},
Voices: []store.VoiceProfile{{Src: "赵大", SinceCh: 1}},
Delta: seed.File{Terms: []seed.Term{
{Src: "花月", Dst: "Хуа Юэ", Status: "approved", Aliases: []seed.Alias{{Alias: "月妹", Type: "mined"}}},
// A row whose OWN src is a surface of the SIGNED seed term above (赵大's alias 老赵). It is
// the collision the seed-conflict carve-out exists to let the owner repair, and without it
// in the fixture the corpus cannot reach the state whose re-send used to be refused —
// oracle 4 owned the property and never got to exercise it.
{Src: "老赵", Dst: "Старина Чжао", Status: "approved"},
}},
Rejects: seed.RejectFile{Rejects: []seed.Reject{{Src: "水", Note: "n"}}},
Decisions: doc.Decisions,
})
})
}
// FuzzSeedDocumentRoundTrip pins the property the whole canonical-rewrite design rests on: what the
// engine WRITES, the engine can READ BACK, unchanged.
//
// Without it the byte-level idempotency contract is a hope. The door decides "this file needs no write"
// by rendering the document and comparing bytes; if rendering a parsed document could produce something
// that parses differently — or does not parse at all — a retry would either loop rewriting the same file
// or write a file the next run refuses. Both are silent until a paid run dies.
//
// Stated as: parse(x) = A ⟹ parse(render(A)) = A, and render(parse(render(A))) is byte-identical to
// render(A). Refusals at the first parse are an acceptable answer to arbitrary bytes.
func FuzzSeedDocumentRoundTrip(f *testing.F) {
f.Add("terms:\n - src: 方源\n dst: Фан Юань\n status: approved\n")
f.Add("terms: []\nvoices: []\naddresses: []\n")
f.Add("# only a comment\n")
f.Add("")
f.Add("terms:\n - src: \" пробелы \"\n dst: x\n aliases:\n - {alias: a, type: mined}\n")
f.Add("terms:\n - src: a\n dst: b\n decl: {invariant: true, forms: []}\n")
f.Add("voices:\n - src: a\n since_ch: 1\n")
f.Fuzz(func(t *testing.T, raw string) {
first, err := seed.DecodeFile([]byte(raw))
if err != nil {
return // refusing arbitrary bytes is always acceptable
}
rendered, err := RenderSeedFile(first)
if err != nil {
t.Fatalf("a document that PARSED cannot be rendered: %v", err)
}
second, err := seed.DecodeFile(rendered)
if err != nil {
t.Fatalf("the engine rendered a document it cannot read back: %v\n%s", err, rendered)
}
again, err := RenderSeedFile(second)
if err != nil {
t.Fatalf("re-render failed: %v", err)
}
if string(rendered) != string(again) {
t.Fatalf("rendering is not a fixed point — a retry would rewrite the file forever\nfirst:\n%s\nsecond:\n%s", rendered, again)
}
})
}