textmachine/backend/internal/membank/decisions_converge_test.go

218 lines
12 KiB
Go

package membank
import (
"strings"
"testing"
"textmachine/backend/internal/seed"
"textmachine/backend/internal/store"
)
// decisions_converge_test.go: the door's own idempotency contract, on the ONE trajectory that broke it.
//
// The contract, in this package's words (AcceptedDecision.State): «a retry of a decision that is
// already in the files changes nothing, writes nothing and still exits 0, because a worker that resumes
// and retries must not be able to split the state.» The published form of the same promise is
// «re-send the SAME document — the retry converges».
//
// It was false for exactly one document: a DECLINE of a surface that belongs to a signed seed term and
// that the mined-delta holds a row for — the lawful livelock repair. The first call is accepted BECAUSE
// the delta holds the row; applying it DROPS the row; and refuseSeedConflicts, which fires when the
// delta has no row, then refuses the identical document forever. The refusal was reading the state its
// own acceptance had produced.
// seedSurfaceState builds the exact state that trajectory needs: a SIGNED seed term, and a mined-delta
// holding a row of its own for the same surface.
func seedSurfaceState() ([]store.GlossaryEntry, seed.File) {
seedRows := []store.GlossaryEntry{bankRow("方源", "Фан Юань", "approved", "seed")}
delta := seed.File{Terms: []seed.Term{{Src: "方源", Dst: "Фан-Юань", Status: "auto"}}}
return seedRows, delta
}
func declineOf(src string) Decision { return Decision{Action: ActionDecline, Src: src} }
// TestTheSameDeclineDocumentConverges is the ordered invariant: send it, apply it, send it again — and
// the second answer is «already applied», not a refusal.
func TestTheSameDeclineDocumentConverges(t *testing.T) {
seedRows, delta := seedSurfaceState()
doc := []Decision{declineOf("方源")}
first := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Decisions: doc})
if len(first.Rejected) > 0 {
t.Fatalf("the FIRST send is lawful (the delta holds a row of its own): %+v", first.Rejected)
}
if len(first.Accepted) != 1 || first.Accepted[0].State != StateApplied {
t.Fatalf("first send = %+v", first.Accepted)
}
if !first.DeltaTouched || !first.RejectsTouched {
t.Fatalf("a decline over a held surface must touch BOTH documents: %+v", first)
}
// The re-send sees the state the first one produced — which is the whole point.
second := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: first.Delta, Rejects: first.Rejects, Decisions: doc})
if len(second.Rejected) > 0 {
t.Fatalf("the SAME document was refused on re-send — the retry does not converge: %+v", second.Rejected)
}
if len(second.Accepted) != 1 || second.Accepted[0].State != StateAlreadyApplied {
t.Fatalf("the re-send must answer already_applied, got %+v", second.Accepted)
}
if second.DeltaTouched || second.RejectsTouched {
t.Fatalf("a converged re-send must change nothing: delta=%v rejects=%v", second.DeltaTouched, second.RejectsTouched)
}
// And a THIRD send is the same answer: convergence is a fixed point, not a one-off tolerance.
third := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: second.Delta, Rejects: second.Rejects, Decisions: doc})
if len(third.Rejected) > 0 || third.Accepted[0].State != StateAlreadyApplied {
t.Fatalf("third send = %+v / %+v", third.Accepted, third.Rejected)
}
}
// TestAnAcceptedDeclineDoesNotPoisonTheRestOfItsDocument: the layer is all-or-nothing, so the refusal
// did not merely strand the decline — it discarded every lawful decision sent beside it.
func TestAnAcceptedDeclineDoesNotPoisonTheRestOfItsDocument(t *testing.T) {
seedRows, delta := seedSurfaceState()
doc := []Decision{declineOf("方源"), approve("李青", "Ли Цин")}
first := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Decisions: doc})
if len(first.Rejected) > 0 {
t.Fatalf("first send: %+v", first.Rejected)
}
second := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: first.Delta, Rejects: first.Rejects, Decisions: doc})
if len(second.Rejected) > 0 {
t.Fatalf("the re-send refused, taking the lawful approve down with it: %+v", second.Rejected)
}
if len(second.Accepted) != 2 {
t.Fatalf("both decisions must be answered, got %+v", second.Accepted)
}
for _, a := range second.Accepted {
if a.State != StateAlreadyApplied {
t.Fatalf("every decision of a fully-landed document is already_applied, got %+v", a)
}
}
}
// TestTheHalfStateOfAnInterruptedDeclineConverges is the case where a refusal was not an inconvenience
// but a locked door: the process died between the two renames. Under the rejects-first order the
// surviving half is the REJECTS and the delta still holds the row, so the identical document finishes
// the job. (The engine's own writeDecisionFiles pins that order; this pins what the order buys.)
func TestTheHalfStateOfAnInterruptedDeclineConverges(t *testing.T) {
seedRows, delta := seedSurfaceState()
doc := []Decision{declineOf("方源")}
full := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Decisions: doc})
if len(full.Rejected) > 0 {
t.Fatalf("setup: %+v", full.Rejected)
}
// Half-state: the REJECTS landed, the delta did not (it still holds the row).
half := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Rejects: full.Rejects, Decisions: doc})
if len(half.Rejected) > 0 {
t.Fatalf("an interrupted decline must be finishable by re-sending the same document: %+v", half.Rejected)
}
if !half.DeltaTouched {
t.Fatal("the re-send must finish the unwritten half (drop the delta row)")
}
if deltaHoldsSurface(half.Delta, "方源") {
t.Fatal("after the converged re-send the delta must no longer hold the declined surface")
}
// The finished state is then the fixed point.
again := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: half.Delta, Rejects: half.Rejects, Decisions: doc})
if len(again.Rejected) > 0 || again.DeltaTouched || again.RejectsTouched {
t.Fatalf("after convergence the document must be inert: %+v / %+v", again.Rejected, again.Accepted)
}
}
// TestAGenuinelyInertDeclineIsStillRefused is the DIRECTION GUARD, and it is half the order: the
// refusal that was narrowed is load-bearing and was bought with a livelock. A decline of a seed surface
// with NO delta row of its own AND no reject on the record still refuses — with the same sentence.
func TestAGenuinelyInertDeclineIsStillRefused(t *testing.T) {
seedRows := []store.GlossaryEntry{bankRow("方源", "Фан Юань", "approved", "seed")}
res := ApplyDecisions(ApplyInput{Seed: seedRows, Decisions: []Decision{declineOf("方源")}})
if len(res.Rejected) != 1 {
t.Fatalf("an inert decline of a signed seed surface must still be refused, got %+v", res.Rejected)
}
if r := res.Rejected[0].Reason; !strings.Contains(r, "filters PROPOSALS") ||
!strings.Contains(r, "Remove it from glossary_seed instead") {
t.Fatalf("the refusal must keep its own words, got %q", r)
}
// The narrowing is EXACTLY «already on the record», nothing wider: an unrelated reject does not
// license it.
other := seed.RejectFile{Rejects: []seed.Reject{{Src: "李青"}}}
res2 := ApplyDecisions(ApplyInput{Seed: seedRows, Rejects: other, Decisions: []Decision{declineOf("方源")}})
if len(res2.Rejected) != 1 {
t.Fatalf("a reject for a DIFFERENT surface must not lift the refusal, got %+v", res2.Rejected)
}
}
// TestARepeatedInertDeclineConverges closes the pair: once the refusal has been lifted for a surface
// that IS on the record, the record is what decides — including for a surface the owner declined when
// it was still a live delta row and which the seed later grew to cover.
func TestARepeatedInertDeclineConverges(t *testing.T) {
seedRows := []store.GlossaryEntry{bankRow("方源", "Фан Юань", "approved", "seed")}
onRecord := seed.RejectFile{Rejects: []seed.Reject{{Src: "方源", Note: "решено владельцем"}}}
res := ApplyDecisions(ApplyInput{Seed: seedRows, Rejects: onRecord, Decisions: []Decision{declineOf("方源")}})
if len(res.Rejected) > 0 {
t.Fatalf("a decline already on the record must converge, not refuse: %+v", res.Rejected)
}
if len(res.Accepted) != 1 || res.Accepted[0].State != StateAlreadyApplied {
t.Fatalf("got %+v", res.Accepted)
}
if res.RejectsTouched {
t.Fatal("nothing to write: the reject is already there")
}
// The owner's own words survive the repeat (the note rule of addReject).
if res.Rejects.Rejects[0].Note != "решено владельцем" {
t.Fatalf("the repeat erased the owner's note: %+v", res.Rejects.Rejects[0])
}
}
// TestTheStandingLedgerConvergesThroughBothDoors is the trajectory an adversarial pass walked, and every
// step of it is an ACCEPTED call — no crash, no hand edit. A caller who keeps one document as the
// standing ledger of their decisions (which is the workflow the published retry describes) re-sends it
// and, before the fix, was refused by the OTHER inert-decline door — losing the lawful approve beside it,
// because the layer is all-or-nothing.
func TestTheStandingLedgerConvergesThroughBothDoors(t *testing.T) {
// 方小子 starts as a delta term of its OWN, so declining it is lawful and changes something.
delta := seed.File{Terms: []seed.Term{{Src: "方小子", Dst: "Малыш Фан", Status: "approved"}}}
// The bank offers 方源 with 方小子 among its aliases, so approving 方源 later makes 方小子 an alias.
bank := []store.GlossaryEntry{{Src: "方源", Status: "auto", Source: "mined",
Aliases: []store.GlossaryAlias{{Alias: "方小子", AliasType: "mined"}}}}
step1 := ApplyDecisions(ApplyInput{Bank: bank, Delta: delta, Decisions: []Decision{declineOf("方小子")}})
if len(step1.Rejected) > 0 {
t.Fatalf("step 1 (decline a term of its own) is lawful: %+v", step1.Rejected)
}
step2 := ApplyDecisions(ApplyInput{Bank: bank, Delta: step1.Delta, Rejects: step1.Rejects,
Decisions: []Decision{{Action: ActionApprove, ID: TermID("方源", "", 0, 0), Dst: "Фан Юань"}}})
if len(step2.Rejected) > 0 {
t.Fatalf("step 2 (approve the owning term) is lawful: %+v", step2.Rejected)
}
// The standing ledger: BOTH decisions, re-sent over the state they themselves produced.
ledger := []Decision{declineOf("方小子"), {Action: ActionApprove, ID: TermID("方源", "", 0, 0), Dst: "Фан Юань"}}
res := ApplyDecisions(ApplyInput{Bank: bank, Delta: step2.Delta, Rejects: step2.Rejects, Decisions: ledger})
if len(res.Rejected) > 0 {
t.Fatalf("the standing ledger was refused on re-send — and an all-or-nothing door discards the lawful approve with it: %+v", res.Rejected)
}
for _, a := range res.Accepted {
if a.State != StateAlreadyApplied {
t.Fatalf("every decision of a fully-landed ledger is already_applied, got %+v", a)
}
}
if res.DeltaTouched || res.RejectsTouched {
t.Fatal("a converged re-send must change nothing")
}
}
// TestDecliningALiveAliasIsStillRefused is the direction guard for the SECOND door: the alias refusal is
// load-bearing (declining an alias does not withdraw the term, and the owner has to be told to decline
// the term itself), and the narrowing must not have opened it for a surface nobody has decided about.
func TestDecliningALiveAliasIsStillRefused(t *testing.T) {
delta := seed.File{Terms: []seed.Term{{Src: "方源", Dst: "Фан Юань", Status: "approved",
Aliases: []seed.Alias{{Alias: "方小子"}}}}}
res := ApplyDecisions(ApplyInput{Delta: delta, Decisions: []Decision{declineOf("方小子")}})
if len(res.Rejected) != 1 {
t.Fatalf("declining a live alias must still be refused, got %+v", res.Rejected)
}
if r := res.Rejected[0].Reason; !strings.Contains(r, "is an ALIAS of the approved term") {
t.Fatalf("the refusal must keep its own words, got %q", r)
}
}