textmachine/backend/internal/pipeline/bankchain_test.go

336 lines
16 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"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/config"
"textmachine/backend/internal/membank"
"textmachine/backend/internal/seed"
)
// bankchain_test.go: THE CHAIN, end to end, at $0 — under the D39.144 flag model, two chains.
//
// The first chain is the door's round-trip: stop → the owner decides the WHOLE map through `bank-apply`
// → the delta empties (decided terms are excluded from mining as seed surfaces and rejects) and the run
// goes on with the promotion in the bank as a SIGNED row. What no partial test can see is the LIVELOCK:
// a chain in which every step reports success and the stop nevertheless re-fires forever, because the
// promotion is written in a form the next run filters out (unified backlog row 199, format mine №1: a
// mechanically copied signature row carries `status: auto`, and unsignedEngineSurfaces drops exactly
// that). ⚠ Under the flag model «the run went on» alone would be a VACUOUS oracle for this chain — the
// presented memory clears the stop whatever the decisions say — so the chain asserts the decision-driven
// observable instead: the delta itself is EMPTY on the resume (lastMinedCount == 0).
//
// The second chain (TestTheStopIsAFlagNotAGateOnDecisions) is the flag model itself, and it is the pin
// §4.11 demanded: a return to «стоп гаснет, когда решён каждый терм» reddens it. It replaces the old
// name TestTheStopClearsWhenTheOwnerDecidesTheWholeSignatureMap's implicit claim that deciding is what
// clears the stop.
//
// Both run against the fake provider the rest of this file uses, so they cost nothing.
//
// ⚠ Bound of the fixture, stated because it is easy to over-read: the synthetic banknote contrast changes
// WHICH candidates are proposed, not whether a signed row passes the signature filter. For the question
// "does the stop go out" that is exactly right. For "is the miner any good" it proves nothing.
// TestTheChainOwnerDecidesTheWholeMapAndTheRunGoesOn runs the door's round-trip as one execution: red
// stop → decisions over the whole map → an EMPTY delta on resume and the edit wave running.
func TestTheChainOwnerDecidesTheWholeMapAndTheRunGoesOn(t *testing.T) {
rec := &reqRec{}
editSeen := false
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
editSeen = true
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{})
// (1) The stop. ONE project throughout: a second, fresh project would answer a different question —
// the sibling reject test ends on a project whose signature map was never written, and its final
// assertion is nearly vacuous for exactly that reason.
r1 := newVerifyRunner(t, bookPath)
stop := runToSignatureStop(t, r1)
rawMap := readSignatureMap(t, r1)
sigPath := r1.signatureMapPath()
// The door takes the project's own flock, which this runner is holding: a live run and a decision are
// mutually exclusive by design (17-seam-inbound-law п.2), and this is the workflow — the operator
// decides between runs.
r1.Close()
if editSeen {
t.Fatal("test premise broken: the stopped run reached the edit wave")
}
// (2) The document a signing screen would build: everything the map proposes a rendering for is
// approved, everything WHICH-only is declined (it CANNOT be approved — an approved row with no dst
// fails the next load, which is format mine №2 of row 199).
sigMap, err := seed.DecodeSignatureMap([]byte(rawMap))
if err != nil {
t.Fatalf("the signature map must parse as the enveloped seed schema it is written in: %v\n%s", err, rawMap)
}
if sigMap.Version != seed.SignatureMapVersion || sigMap.ID == "" {
t.Fatalf("the map must carry the seam envelope (version + content id), got version=%q id=%q", sigMap.Version, sigMap.ID)
}
sig := sigMap.File
book, err := config.LoadBook(bookPath)
if err != nil {
t.Fatal(err)
}
doc := membank.DecisionsDoc{Version: membank.DecisionsVersion, BookID: book.BookID}
approves, declines := 0, 0
for _, term := range sig.Terms {
if strings.TrimSpace(term.Dst) != "" {
doc.Decisions = append(doc.Decisions, membank.Decision{
Action: membank.ActionApprove, Src: term.Src, Sense: term.Sense,
SinceChapter: term.SinceCh, UntilChapter: term.UntilCh,
Dst: term.Dst, Note: "signed from the signature map",
})
approves++
continue
}
doc.Decisions = append(doc.Decisions, membank.Decision{
Action: membank.ActionDecline, Src: term.Src, Note: "no rendering to sign",
})
declines++
}
// The fixture's own shape, asserted so a change to it shows up here as a premise failure rather than
// as a mysteriously passing chain: two proposed terms, one carrying the banknote's dst and one bare.
if len(sig.Terms) != stop.Terms || approves != 1 || declines != 1 {
t.Fatalf("test premise broken: %d terms in the map (stop said %d), %d approve + %d decline:\n%s",
len(sig.Terms), stop.Terms, approves, declines, rawMap)
}
// (3) The door. The fixture declares NEITHER decision key, so this also exercises the conventional
// defaults end to end — nobody created a file in advance and nobody named a path.
docPath := filepath.Join(t.TempDir(), "decisions.json")
body, err := json.Marshal(doc)
if err != nil {
t.Fatal(err)
}
writeFile(t, docPath, string(body))
// A PARTIAL document first, as a projection. This is the case the report used to answer dishonestly:
// `changed: true, accepted: 1, rejected: []` reads as done, and the next run stops again on the
// surface nobody decided. The projection is where a caller can still see it, and `signature.undecided`
// is where it is said. (Projection semantics: the number is what WOULD hold after applying, which is
// the only reading a preview can have.)
partial := membank.DecisionsDoc{Version: membank.DecisionsVersion, BookID: book.BookID,
Decisions: doc.Decisions[:1]}
partialPath := filepath.Join(t.TempDir(), "partial.json")
partialBody, err := json.Marshal(partial)
if err != nil {
t.Fatal(err)
}
writeFile(t, partialPath, string(partialBody))
proj, err := ApplyBankDecisions(t.Context(), bookPath, partialPath, true)
if err != nil {
t.Fatalf("the projection must not refuse a partial set: %v", err)
}
if proj.Mode != "projection" || proj.Signature.Surfaces != len(sig.Terms) || proj.Signature.Undecided != len(sig.Terms)-1 {
t.Fatalf("a set that decides one of %d surfaces must report the rest as undecided: mode=%s surfaces=%d undecided=%d",
len(sig.Terms), proj.Mode, proj.Signature.Surfaces, proj.Signature.Undecided)
}
if _, err := os.Stat(filepath.Join(filepath.Dir(bookPath), book.BookID+config.MinedDeltaSuffix)); err == nil {
t.Fatal("the projection wrote a decision file")
}
rep, err := ApplyBankDecisions(t.Context(), bookPath, docPath, false)
if err != nil {
t.Fatalf("the whole signature map must be applicable in one act: %v (%+v)", err, rep.Rejected)
}
if !rep.Changed || len(rep.Accepted) != len(doc.Decisions) || len(rep.Rejected) != 0 {
t.Fatalf("changed=%v accepted=%d rejected=%+v", rep.Changed, len(rep.Accepted), rep.Rejected)
}
dir := filepath.Dir(bookPath)
wantDelta := filepath.Join(dir, book.BookID+config.MinedDeltaSuffix)
if rep.Files.MinedDelta != wantDelta {
t.Fatalf("the conventional delta path must be <book dir>/<book_id>%s: %q != %q", config.MinedDeltaSuffix, rep.Files.MinedDelta, wantDelta)
}
if rep.Signature.Undecided != 0 {
t.Fatalf("after deciding every surface of the map nothing may be left undecided, got %d of %d",
rep.Signature.Undecided, rep.Signature.Surfaces)
}
// (4) The resume. The stop is GONE and the run went on — and because the presented memory would clear
// the stop regardless, the DECISION-driven observable is the delta itself: every proposed term was
// promoted or declined, so mining must exclude them all and propose NOTHING. A promotion written in a
// form the next run filters out (the row-199 livelock) fails exactly here, as a non-empty delta.
r2 := newVerifyRunner(t, bookPath)
defer r2.Close()
res, err := r2.TranslateBook(context.Background())
var again *WaveSignatureStop
if errors.As(err, &again) {
t.Fatalf("the stop re-fired on %d terms after the owner decided every one of them — the livelock of row 199:\n%s",
again.Terms, readSignatureMap(t, r2))
}
if err != nil {
t.Fatalf("the resumed run failed: %v", err)
}
if r2.lastMinedCount != 0 {
t.Fatalf("the owner decided every proposed term, yet mining still proposes %d — the decisions did not reach the exclusion filters", r2.lastMinedCount)
}
if !editSeen || res == nil || len(res.Chunks) == 0 {
t.Fatalf("the edit wave must have run after the stop cleared (edit_seen=%v, res=%+v)", editSeen, res)
}
// The approved rendering is in the bank as a SIGNED row — the promotion survived being read back,
// which is the half of the chain the format mines attack.
rows, err := r2.Store.GlossaryForBook(book.BookID)
if err != nil {
t.Fatal(err)
}
signed := false
for _, e := range rows {
if e.Src == "方源" && e.Status == "approved" {
signed = true
}
}
if !signed {
t.Fatalf("the approved term must be in the bank as approved:\n%+v", rows)
}
// ⚠ PRE-EXISTING, pinned here so it is a KNOWN state rather than a surprise: a cleared stop leaves the
// last signature map on disk. Nothing deletes it (`os.Remove` does not occur in mining.go), so a
// consumer that reads the file to answer "is there something to sign?" reads a map that has already
// been signed. The engine itself does not care — it re-mines from the source every run — and this is
// not the pack's doing, so it is a ping and not a change. The report's `signature.undecided` is the
// field that answers that question truthfully.
if _, err := os.Stat(sigPath); err != nil {
t.Fatalf("this asserts the CURRENT behaviour: the signature map stays after the stop clears; it is gone: %v", err)
}
}
// TestTheStopIsAFlagNotAGateOnDecisions is the flag model (D39.144) as one chain, and the pin §4.11
// demanded: no other test reddens when the engine reverts to «стоп гаснет, когда каждый терм промотирован
// или отклонён». Three runs on ONE project, the flag raised throughout, the owner deciding NOTHING:
//
// run 1 — the map holds one term (the other is pre-declined): STOP, the term enters the memory;
// run 2 — the decline is withdrawn, the delta gains a term no stop has presented: STOP again, on
// novelty and only novelty;
// run 3 — nothing new: NO stop, the run completes with the unsigned rows riding to the editor.
//
// Under the old semantics run 3 stops forever (nothing was ever decided); under a memory that forgets
// clusters, run 3 re-stops too. Either regression reddens exactly here.
func TestTheStopIsAFlagNotAGateOnDecisions(t *testing.T) {
rec := &reqRec{}
allowEdit := false
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
if !allowEdit {
t.Errorf("the edit wave ran despite a live bank-mining stop")
}
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{rejects: "rejects:\n - src: 方源\n"})
dir := filepath.Dir(bookPath)
// Run 1: 方源 is declined up front, so the map holds only the OTHER candidate — the memory must
// record what was PRESENTED, not what was mined.
r1 := newVerifyRunner(t, bookPath)
stop1 := runToSignatureStop(t, r1)
map1 := readSignatureMap(t, r1)
r1.Close()
if stop1.Terms != 1 || strings.Contains(map1, "src: 方源") || !strings.Contains(map1, "src: 花家") {
t.Fatalf("test premise broken: run 1 must present exactly 花家 (方源 is declined), got %d terms:\n%s", stop1.Terms, map1)
}
// Run 2: the decline is withdrawn, so 方源 enters the delta for the first time — a surface outside
// the memory. The flag must trip AGAIN: «остановиться один раз» is per novelty, not per book.
if err := os.Remove(filepath.Join(dir, "test-book.mined-rejects.yaml")); err != nil {
t.Fatal(err)
}
r2 := newVerifyRunner(t, bookPath)
stop2 := runToSignatureStop(t, r2)
r2.Close()
if stop2.Terms != 2 {
t.Fatalf("run 2 holds a never-presented surface and must stop on it, got %d terms", stop2.Terms)
}
// Run 3: the same delta again, everything already presented, nothing decided. The flag stays up and
// the run must go THROUGH: no stop, the auto wire on, the unsigned rows in the bank for the editor.
allowEdit = true
r3 := newVerifyRunner(t, bookPath)
defer r3.Close()
res, err := r3.TranslateBook(context.Background())
var again *WaveSignatureStop
if errors.As(err, &again) {
t.Fatalf("run 3 re-stopped on %d terms the owner has already been shown twice — the stop is a gate on decisions again, not a flag", again.Terms)
}
if err != nil {
t.Fatalf("run 3 must complete: %v", err)
}
if res == nil || len(res.Chunks) == 0 {
t.Fatalf("run 3 must produce the book, got %+v", res)
}
if _, err := os.Stat(r3.autoBankPath()); err != nil {
t.Fatalf("the non-stopping run must write the auto-bank: %v", err)
}
rows, err := r3.Store.GlossaryForBook("test-book")
if err != nil {
t.Fatal(err)
}
unsigned := 0
for _, e := range rows {
if e.Source == "mined" && e.Status != "approved" {
unsigned++
}
}
if unsigned == 0 {
t.Fatalf("«неподписанные строки едут редактору» must hold in the raised-flag mode: no unsigned mined rows in the bank:\n%+v", rows)
}
}
// TestNoveltyFromTheReverseSectionAloneTripsTheFlag closes the half of the §4.1 domain the other pins
// cannot reach: «обе секции» means the REVERSE section's terms arm the flag exactly like the miner's
// own. Run 1 pre-declines the banknote-only surface, so the map holds only miner terms and the memory
// records only them; run 2 withdraws the decline — the surface re-enters through the REVERSE door (the
// miner structurally cannot emit it) and the stop must fire on that novelty alone.
func TestNoveltyFromTheReverseSectionAloneTripsTheFlag(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "青茅山\tгора Цинмао\n方源\tФан Юань", "stop"
}
if isEditBody(body) {
t.Errorf("the edit wave ran despite a live bank-mining stop")
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{
terminology: true, rejects: "rejects:\n - src: 青茅山\n",
})
dir := filepath.Dir(bookPath)
r1 := newVerifyRunner(t, bookPath)
stop1 := runToSignatureStop(t, r1)
map1 := readSignatureMap(t, r1)
r1.Close()
if strings.Contains(map1, "src: 青茅山") {
t.Fatalf("test premise broken: the declined banknote-only surface must be absent from run 1's map:\n%s", map1)
}
// The decline is withdrawn: the reverse section re-admits 青茅山, and it is the ONLY novelty.
if err := os.Remove(filepath.Join(dir, "test-book.mined-rejects.yaml")); err != nil {
t.Fatal(err)
}
r2 := newVerifyRunner(t, bookPath)
defer r2.Close()
stop2 := runToSignatureStop(t, r2)
if stop2.Terms != stop1.Terms+1 {
t.Fatalf("run 2's novelty is exactly the reverse surface: %d terms vs %d", stop2.Terms, stop1.Terms)
}
map2 := readSignatureMap(t, r2)
if !strings.Contains(map2, "src: 青茅山") || !strings.Contains(map2, "banknote-only candidate") {
t.Fatalf("the reverse surface must be the stop's reason, labelled as reverse:\n%s", map2)
}
}