336 lines
16 KiB
Go
336 lines
16 KiB
Go
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)
|
||
}
|
||
}
|