textmachine/backend/internal/pipeline/miningstop_join_test.go

1591 lines
70 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 (
"bytes"
"context"
"errors"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/chunk/chunktest"
"textmachine/backend/internal/config"
"textmachine/backend/internal/lang"
"textmachine/backend/internal/membank"
"textmachine/backend/internal/miner"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/store"
"textmachine/backend/internal/terminology"
)
// miningstop_join_test.go: the FIRST end-to-end fixture that actually lights the bank-mining stop
// (mining.go:40-102). Until pack-20 no test in the suite set `mining.contrast_path`, so the stop's guard
// (mining.go:37) returned false on every run and the whole body — the miner call, the reject set, the
// WHAT-join, the signature-map write — was executed ZERO times per suite. That is why the D39.37 fix-list
// item №1 was true: mutating the join call-site to `DeltaYAML(mined, nil)` survived the entire suite.
// The diagnosis was in fact wider than recorded (S8): not one mute line, the whole range.
//
// What these fixtures pin, in the order a paid run would discover it:
// - the stop FIRES on a non-empty delta, before the edit wave, and the edit wave never runs;
// - the WHAT-join is real: a term the miner found (WHICH) carries the dst the banknote proposed (WHAT)
// into the signature map, with the provenance line that says it is NOT approved;
// - the join key survives normalization (the model writes its own orthography);
// - a RESUME re-serves the same map — the owner signs on the SECOND run, and the resume fast-path
// never touches the raw checkpoint, so a lost banknote_detail would silently empty the WHAT column
// (waverun.go:308-318);
// - the owner's reject list empties the delta and clears the stop (mining.go:64-68).
//
// The fixture is a REAL zh→ru project against the in-repo langpack — the miner is pair-data-driven, so a
// patched runner would prove nothing about the path production takes.
// miningContrastData is a tiny general-zh reference (the same shape as the miner's own smallContrast):
// common function/name chars carry high frequency, so the book's repeated entity surfaces stand out.
const miningContrastData = "的 100000 uj\n是 80000 v\n人 50000 n\n出 9000 v\n现 9000 v\n" +
"月 4000 n\n光 3800 n\n希 900 n\n望 3000 v\n影 800 n\n在 20000 p\n此 1200 r\n" +
"古 400 nr\n方 900 nr\n源 300 n\n族 500 n\n长 6000 a\n青 700 n\n茅 20 n\n山 5000 n\n" +
"四 8000 m\n代 3000 n\n甲 300 n\n等 12000 u\n正 4000 a\n"
// miningStopSource is the corpus the emission filters actually fire on (freq ≥ 5, type ∈ name/place/title):
// 方源 and 花家 both clear the floor. It is the same corpus the miner/seed contract fixtures use, so the
// two layers are pinned against one candidate set.
var miningStopSource = strings.Repeat("方源来到青茅山。方源很强。花家很大。花家的人。", 6)
// miningStopOpts tunes the mining-stop fixture.
type miningStopOpts struct {
rejects string // mined-rejects YAML body; "" = no reject file
glossarySeed string // glossary seed YAML body; "" = no seed
source string // book source (defaults to miningStopSource)
terminology bool // enable the terminologist role (pack-20) with a fixture prompt
batchRunes int // gates.terminology.batch_runes; 0 = engine default (one batch for this corpus)
budgetUSD float64 // gates.terminology.budget_usd; 0 = 1.0 (effectively unbounded here)
targetScript string // gates.terminology.target_script; "" = Cyrillic (the fixtures' target)
}
// bankBlockForMining is the banknote a translator emits over this corpus: one line whose src IS a mined
// candidate (the join's non-empty intersection — the whole point of the fixture) and one that is not.
const bankBlockForMining = bankSeparator + "\n方源\tФан Юань\tname\n青茅山\tгора Цинмао\tplace"
// setupMiningStopProject builds a zh→ru fixture with the bank-mining stop CONFIGURED (contrast artifact +
// langpack) and the banknote channel ON, so both halves of the WHAT→WHICH join are live. Local
// constructor by the repair_integration_test.go precedent: the shared setupProjectOpts stays untouched.
func setupMiningStopProject(t *testing.T, providerURL string, o miningStopOpts) string {
t.Helper()
if o.source == "" {
o.source = miningStopSource
}
// One gates block, authored here: setupProjectOpts appends its own `gates:` for the banknote flag, and
// two top-level `gates:` keys are a YAML parse error, not a merge.
gates := "\nmining:\n contrast_path: mining-contrast.txt\ngates:\n banknote:\n enabled: true\n"
if o.terminology {
budget := o.budgetUSD
if budget <= 0 {
budget = 1.0
}
script := o.targetScript
if script == "" {
script = "Cyrillic" // the fixtures translate into Russian; an enabled gate must declare a script
}
gates += fmt.Sprintf(" terminology:\n enabled: true\n model: fake-model\n budget_usd: %g\n target_script: %s\n", budget, script)
if o.batchRunes > 0 {
gates += fmt.Sprintf(" batch_runes: %d\n", o.batchRunes)
}
}
bookPath := setupProjectOpts(t, providerURL, projectOpts{
source: o.source,
glossarySeed: o.glossarySeed,
gatesYAML: gates,
})
dir := filepath.Dir(bookPath)
writeFile(t, filepath.Join(dir, "mining-contrast.txt"), miningContrastData)
if o.terminology {
// The role prompt is resolved by the ordinary `<prompts root>/<pair>/<role>.md` convention, so the
// fixture must lay out a real pair prompt pack — the same path production takes.
writeFile(t, filepath.Join(dir, "pairs", "zh-ru.yaml"), "pair: zh-ru\nprompts_root: ../prompts\n")
writeFile(t, filepath.Join(dir, "prompts", "zh-ru", "terminologist.md"),
"Ты — терминолог {{source_lang}}→{{target_lang}}.\n---USER---\nТермины книги:\n\n{{text}}")
}
packRoot, err := filepath.Abs("../../configs/langpacks")
if err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(bookPath)
if err != nil {
t.Fatal(err)
}
body := strings.Replace(string(raw), "source_lang: ja", "source_lang: zh\nlangpack_root: "+packRoot, 1)
if o.rejects != "" {
writeFile(t, filepath.Join(dir, "mined-rejects.yaml"), o.rejects)
body += "\nmined_rejects: mined-rejects.yaml\n"
}
writeFile(t, bookPath, body)
return bookPath
}
// miningStopProvider answers the draft with a clean RU translation plus the banknote block, and fails the
// test if the EDIT stage is ever reached (the stop must land before the edit wave).
func miningStopProvider(t *testing.T, rec *reqRec, draft string) func(string) (string, string) {
t.Helper()
if draft == "" {
draft = "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining
}
return func(body string) (string, string) {
if isEditBody(body) {
t.Errorf("the edit wave ran despite the bank-mining stop")
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return draft, "stop"
}
}
// newVerifyRunner opens the fixture runner in the VERIFY-BANK mode. Every fixture here is about the stop
// itself, and the stop is opt-in since pack-20: the DEFAULT is to carry the unsigned bank forward, which
// TestBankStopDefaultIsAutoContinue pins separately.
func newVerifyRunner(t *testing.T, bookPath string) *Runner {
t.Helper()
r := newRunner(t, bookPath)
r.VerifyBank = true
return r
}
// readSignatureMap returns the signature map the stop wrote (failing loudly if it is absent).
func readSignatureMap(t *testing.T, r *Runner) string {
t.Helper()
raw, err := os.ReadFile(r.signatureMapPath())
if err != nil {
t.Fatalf("the stop must write the signature map: %v", err)
}
return string(raw)
}
// runToSignatureStop runs the book and asserts it stopped at the bank-mining boundary, returning the stop.
func runToSignatureStop(t *testing.T, r *Runner) *WaveSignatureStop {
t.Helper()
_, err := r.TranslateBook(context.Background())
var stop *WaveSignatureStop
if !errors.As(err, &stop) {
t.Fatalf("want a WaveSignatureStop, got err=%v", err)
}
if stop.Terms == 0 {
t.Fatal("test premise broken: the stop fired with zero terms")
}
return stop
}
// TestMiningStopJoinsBanknoteDstIntoSignatureMap is the pin D39.37 fix-list №1 asked for: an end-to-end run
// where the miner's WHICH candidate and the banknote's WHAT proposal MEET, and the meeting is visible in
// the artifact the owner signs.
func TestMiningStopJoinsBanknoteDstIntoSignatureMap(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, miningStopProvider(t, rec, ""))
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{})
r := newVerifyRunner(t, bookPath)
defer r.Close()
stop := runToSignatureStop(t, r)
yamlMap := readSignatureMap(t, r)
if stop.SignaturePath != r.signatureMapPath() {
t.Fatalf("stop path %q != %q", stop.SignaturePath, r.signatureMapPath())
}
// (1) The WHICH is there: the miner proposed the term at all.
if !strings.Contains(yamlMap, "src: 方源") {
t.Fatalf("the mined term must be in the signature map:\n%s", yamlMap)
}
// (2) The WHAT reached it — this is the assertion the empty-map mutation must break.
if !strings.Contains(yamlMap, "dst: Фан Юань") {
t.Fatalf("the banknote's proposed dst must be JOINED onto the mined term:\n%s", yamlMap)
}
// (3) …carrying its provenance, so the owner cannot read a proposal as an approved canon.
if !strings.Contains(yamlMap, "NOT approved") {
t.Fatalf("the joined dst must state that it is a PROPOSAL:\n%s", yamlMap)
}
if !strings.Contains(yamlMap, "banknote") {
t.Fatalf("the note must name the channel the dst came from:\n%s", yamlMap)
}
// (4) Nothing the model proposed became trusted: every emitted term stays status:auto.
if strings.Contains(yamlMap, "status: approved") {
t.Fatalf("the miner must NEVER emit approved:\n%s", yamlMap)
}
if !strings.Contains(yamlMap, "status: auto") {
t.Fatalf("emitted terms must be status:auto:\n%s", yamlMap)
}
// (5) A proposal for a term the miner did NOT propose must not invent a row in the map.
if strings.Contains(yamlMap, "src: 青茅山") {
t.Fatalf("the join must not add banknote-only terms to the mined delta:\n%s", yamlMap)
}
// (6) The banknote never leaks into the paid text: the draft that ships is the CLEANED one.
cs, err := r.Store.GetChunkStatus("test-book", 1, 0, "draft")
if err != nil || cs == nil {
t.Fatalf("no draft chunk_status: %v", err)
}
cp, err := r.Store.GetCheckpoint(cs.FinalHash)
if err != nil || cp == nil {
t.Fatalf("no draft checkpoint: %v", err)
}
if strings.Contains(cp.ResponseText, bankSeparator) {
t.Fatalf("the shipped draft still carries the banknote block: %q", cp.ResponseText)
}
}
// TestMiningStopJoinKeyNormalizes pins the join KEY. The model writes the source surface in ITS
// orthography — most commonly TRADITIONAL characters over a simplified source — and the join is defined
// over text.NormalizeSourceKey (trad→simp, NFKC, ignorables dropped) precisely so that mismatch does not
// silently drop the dst. Without the normalization the owner would see a bare term and a proposal for the
// "same" word two rows apart.
//
// Honest bound, established by reading text.NormalizeSourceKey and confirmed by this fixture failing on
// the first attempt: the key normalizer does NOT fold interior whitespace. A model writing «方 源» does
// NOT join. That is a real (small) hole, recorded rather than papered over.
func TestMiningStopJoinKeyNormalizes(t *testing.T) {
rec := &reqRec{}
// 龙山 (simplified) in the source; the banknote writes 龍山 (traditional) — one normalized key.
source := strings.Repeat("方源来到龙山。方源很强。龙山很高。", 6)
draft := "Фан Юань пришёл." + "\n" + bankSeparator + "\n龍山\tгора Лун\tplace"
srv := newJSONProvider(rec, miningStopProvider(t, rec, draft))
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{source: source})
r := newVerifyRunner(t, bookPath)
defer r.Close()
runToSignatureStop(t, r)
yamlMap := readSignatureMap(t, r)
if !strings.Contains(yamlMap, "src: 龙山") {
t.Fatalf("test premise broken: the miner must propose 龙山:\n%s", yamlMap)
}
if !strings.Contains(yamlMap, "dst: гора Лун") {
t.Fatalf("a traditional-orthography src must still join on the normalized key:\n%s", yamlMap)
}
}
// TestMiningStopWHATSurvivesResume is the pin for waverun.go:308-318. The owner signs on a REPEAT run
// (the first run stopped), and that run resumes every draft chunk from its checkpoint — a path that never
// re-parses the raw model output. Without the carry-forward, persistRetrievalState would rewrite the row
// with an empty banknote_detail and the second signature map would come back WHICH-only: the owner would
// meet bare terms exactly as before the D39.36 fix, on the very run where they do the signing.
func TestMiningStopWHATSurvivesResume(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, miningStopProvider(t, rec, ""))
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{})
r1 := newVerifyRunner(t, bookPath)
runToSignatureStop(t, r1)
first := readSignatureMap(t, r1)
callsAfterRun1 := rec.count()
r1.Close()
// Run 2 (new process): the drafts resume at $0 and the stop re-fires — the owner's real workflow.
r2 := newVerifyRunner(t, bookPath)
defer r2.Close()
runToSignatureStop(t, r2)
if rec.count() != callsAfterRun1 {
t.Fatalf("the resumed draft wave must not re-call the provider: %d -> %d", callsAfterRun1, rec.count())
}
second := readSignatureMap(t, r2)
if !strings.Contains(second, "dst: Фан Юань") {
t.Fatalf("the resumed run lost the banknote WHAT — the owner would sign bare terms:\n%s", second)
}
if second != first {
t.Fatalf("the signature map must be byte-stable across a $0 resume:\n--- run1 ---\n%s\n--- run2 ---\n%s", first, second)
}
}
// isTerminologyBody reports whether a mock request is a terminologist call (its user turn carries the
// candidate-block label the role prompt writes, which no other prompt does).
func isTerminologyBody(body string) bool { return strings.Contains(body, "Термины книги") }
// TestTerminologistConsolidatesBankIntoDraftRows is the core pack-20 pin: with the role on, the bank the
// owner meets at the stop carries a CONSOLIDATED rendering per term, emitted in the `draft` mode of
// §C2-7 — the mode that reaches the wire with ⟨проверить⟩ — while a term the role declines stays `auto`,
// inert. Nothing becomes `approved` on either branch.
func TestTerminologistConsolidatesBankIntoDraftRows(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
// 方源 gets a canon; 花家 is explicitly declined — both branches of the two-mode emission.
return "方源\tФан Юань\n花家\t" + terminology.NoDst, "stop"
}
if isEditBody(body) {
t.Errorf("the edit wave ran despite the bank-mining stop")
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
r := newVerifyRunner(t, bookPath)
defer r.Close()
runToSignatureStop(t, r)
yamlMap := readSignatureMap(t, r)
if !strings.Contains(yamlMap, "dst: Фан Юань") || !strings.Contains(yamlMap, "status: draft") {
t.Fatalf("a consolidated term must emit status:draft with its dst:\n%s", yamlMap)
}
if !strings.Contains(yamlMap, "CONSOLIDATED by the terminologist") {
t.Fatalf("the sign map must say WHO produced the rendering:\n%s", yamlMap)
}
if strings.Contains(yamlMap, "status: approved") {
t.Fatalf("the terminologist must never produce an approved row:\n%s", yamlMap)
}
// The declined term keeps the auto mode: no consolidated dst, inert until someone signs it.
declined := termBlock(t, yamlMap, "花家")
if strings.Contains(declined, "status: draft") {
t.Fatalf("a DECLINED term must stay status:auto:\n%s", declined)
}
// The role's outcome is counted honestly, not assumed.
if r.lastTerminology.Consolidated != 1 || r.lastTerminology.Declined != 1 {
t.Fatalf("terminology counters: %+v", r.lastTerminology)
}
if r.lastTerminology.EstimateUSD <= 0 {
t.Fatalf("the pre-call estimate must be computed BEFORE the call, got %v", r.lastTerminology.EstimateUSD)
}
// The money lives in the ordinary durable contour, under this role's own class.
spent, err := r.Store.RoleSpentUSD("test-book", roleTerminologist)
if err != nil {
t.Fatal(err)
}
if spent <= 0 || spent != r.lastTerminology.CumUSD {
t.Fatalf("terminology spend must be attributable in the ledger: ledger=%v result=%v", spent, r.lastTerminology.CumUSD)
}
}
// TestEmissionFunnelIsReported is G10 of the polygon addendum: an empty delta must not READ as "this book
// is clean" when it means "the detector ranked candidates and the emission layer cut every one of them".
// The measured recall of the shipped WHICH channel is 0.0690.103, so the second reading is the common one,
// and a stop that cannot tell the two apart tells the owner the opposite of the truth.
func TestEmissionFunnelIsReported(t *testing.T) {
var logBuf bytes.Buffer
rec := &reqRec{}
// The delta empties → the stop auto-continues → the edit wave legitimately runs, so the provider must
// answer it rather than fail the test.
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// A seed that already holds every surface the fixture corpus can yield → the emission empties, but the
// detector's alphabet does not.
const seed = "terms:\n - src: 方源\n dst: Фан Юань\n status: approved\n decl: { invariant: true, forms: [\"Фан Юань\"] }\n" +
" - src: 花家\n dst: клан Хуа\n status: approved\n decl: { invariant: true, forms: [\"клан Хуа\"] }\n" +
" - src: 青茅山\n dst: гора Цинмао\n status: approved\n decl: { invariant: true, forms: [\"гора Цинмао\"] }\n"
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{glossarySeed: seed})
r := newVerifyRunner(t, bookPath)
defer r.Close()
r.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelInfo}))
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
out := logBuf.String()
for _, field := range []string{"ranked_alphabet=", "after_rank_cap=", "eligible=", "skipped_as_alias_of_seeded="} {
if !strings.Contains(out, field) {
t.Fatalf("the stop must publish the WHICH funnel (%s missing):\n%s", field, out)
}
}
}
// TestCanonAnchorPutsTheSignedBankInFrontOfTheTerminologist is the pin the LIVE probe of 26.07 bought:
// the role was consolidating without ever seeing the rows the owner had already signed, and it used that
// freedom to override a canon-consistent draft (元海空窍: the draft's «апертура моря истинной ци», built
// from two signed rows, replaced by «апертура Первозданного моря»). The step whose purpose is one
// consistent canon must be shown the canon. §C2-3's "agreement with approved siblings" cannot cover this
// alone: it only RANKS renderings the drafts produced, and on that probe 34 of 35 candidates had exactly
// one variant to rank.
func TestCanonAnchorPutsTheSignedBankInFrontOfTheTerminologist(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "方源\tФан Юань", "stop"
}
if isEditBody(body) {
t.Errorf("the edit wave ran despite the bank-mining stop")
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
// 花海 shares 花 with the mined 花家 — the morpheme series relation — and is APPROVED, so it is law.
const seed = "terms:\n - src: 花海\n dst: цветочное море\n status: approved\n decl: { invariant: true, forms: [\"цветочное море\"] }\n"
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, glossarySeed: seed})
r := newVerifyRunner(t, bookPath)
defer r.Close()
runToSignatureStop(t, r)
var termBody string
for _, b := range rec.bodies {
if isTerminologyBody(b) {
termBody = b
break
}
}
if termBody == "" {
t.Fatal("the terminologist was never called")
}
if !strings.Contains(termBody, terminology.CanonMarker) {
t.Fatalf("the signed bank must reach the role's wire under its own marker:\n%s", termBody)
}
// The rendering, not merely the marker: an empty labelled block would pass a marker-only assertion.
if !strings.Contains(termBody, `цветочное море`) {
t.Fatalf("the SIGNED rendering itself must be in front of the role:\n%s", termBody)
}
// D39.47: the signed bank is the ONLY anchor. A second block of pair/genre "industry" renderings rode
// this same message in pack-20 and was removed — prescribing one register to every book of the pair is
// exactly what the owner's signature on THIS book decides instead. The assertion is generic on purpose:
// any second anchor block, whatever it is called, fails here. (The fixture prompt above carries no
// ⟦TM-…⟧ token of its own, so every occurrence on the wire comes from the injected anchor.)
if n := strings.Count(termBody, "⟦TM-"); n != strings.Count(termBody, terminology.CanonMarker) || n == 0 {
t.Fatalf("the canon anchor must be the only block on the role's wire (found %d anchor markers):\n%s", n, termBody)
}
}
// TestReverseSectionIsCappedLikeTheMinersOwnEmission: the miner stops emitting at 200 ranked candidates,
// and until the audit of 26.07 the reverse section beside it was uncapped — every banknote-only surface of
// a whole book would enter the signature map the owner is asked to sign, the auto-bank, and the paid
// terminologist batches. Both doors must bound the same volume, by the same number, ranked the same way.
func TestReverseSectionIsCappedLikeTheMinersOwnEmission(t *testing.T) {
cands := make([]terminology.Candidate, 0, 500)
for i := 0; i < 500; i++ {
cands = append(cands, terminology.Candidate{
Key: fmt.Sprintf("k%03d", i), Src: fmt.Sprintf("k%03d", i), Type: "term",
Origin: terminology.OriginBanknote, Freq: i, KWIC: []string{"ctx"},
})
}
got, eligible := reverseSectionTerms(cands, nil, nil)
if eligible != 500 {
t.Fatalf("the eligible count must report what was dropped, got %d", eligible)
}
if len(got) != miner.EmitRankCap() {
t.Fatalf("the reverse section must obey the miner's own cap: %d rows vs cap %d", len(got), miner.EmitRankCap())
}
// Ranked by frequency, so the cap keeps the terms that matter rather than an alphabetical prefix.
for _, term := range got {
if term.Freq < 300 {
t.Fatalf("the cap must keep the MOST frequent surfaces, found %s (freq %d)", term.Src, term.Freq)
}
}
}
// TestTerminologistSpendIsInTheRunTotal: the role's calls are the only paid work in the pipeline that is
// not addressed to a chunk, so the per-chunk accumulation cannot see them. A paid call that no total
// reports is a call the operator cannot notice.
func TestTerminologistSpendIsInTheRunTotal(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "方源\tФан Юань", "stop"
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
r := newRunner(t, bookPath) // AUTO mode: the run completes, so there is a BookResult to check
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
if r.lastTerminology.CostUSD <= 0 {
t.Fatalf("the role must have spent something to make this test meaningful: %+v", r.lastTerminology)
}
var chunkSum float64
for _, c := range res.Chunks {
chunkSum += c.CostUSD
}
if res.TotalUSD < chunkSum+r.lastTerminology.CostUSD-1e-9 {
t.Fatalf("the run total must include the terminologist's spend: total=%v chunks=%v terminology=%v",
res.TotalUSD, chunkSum, r.lastTerminology.CostUSD)
}
// Directive item 4 asks for the unsigned rows in `status`, not only in `report`: this book now ships
// against rows nobody approved, and the command an operator runs before deciding to keep paying must
// say so.
st, err := r.Status(context.Background())
if err != nil {
t.Fatal(err)
}
if st.UnsignedBankTerms == 0 {
t.Fatal("status must report the bank rows nobody approved (the auto mode just wrote some)")
}
}
// TestTerminologistEmptyReplyIsLoud: a paid batch that comes back unreadable (empty completion, prose,
// truncation) would otherwise be indistinguishable from «the role declined these terms» — which is a
// DECISION in §C2-7 — and the run would exit 0 having bought nothing.
func TestTerminologistEmptyReplyIsLoud(t *testing.T) {
var logBuf bytes.Buffer
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "Извините, я не понял задание.", "stop" // prose: no line the parser can use
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
r := newRunner(t, bookPath)
defer r.Close()
r.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
if !strings.Contains(logBuf.String(), "returned nothing the parser could use") {
t.Fatalf("a paid batch that bought no terminology must say so:\n%s", logBuf.String())
}
}
// TestTerminologistBatchesAndRespectsItsBudget covers two things every earlier terminologist fixture left
// unexercised (audit of 26.07: all 13 invocations ran with batches=1, and no assertion touched the budget):
// - the BATCHING driver, including the per-batch reply scoping — batch 2's reply naming a term that was
// only in batch 1 must be refused, or a model could inject terms it was never shown;
// - gates.terminology.budget_usd, the only bound on what this paid role can spend on a book. A budget
// that stops after the first batch must leave the rest unconsolidated instead of buying them anyway.
func TestTerminologistBatchesAndRespectsItsBudget(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
// Answer for EVERY term the fixture knows, in every batch: a batch may only take the terms it
// was actually shown, and this reply proves the scoping rather than assuming it.
return "方源\tФан Юань\n花家\tклан Хуа\n青茅山\tгора Цинмао", "stop"
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
// batch_runes small enough that the three candidates cannot share one call.
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, batchRunes: 400})
r := newVerifyRunner(t, bookPath)
runToSignatureStop(t, r)
if r.lastTerminology.Batches < 2 {
t.Fatalf("the fixture must actually batch, got %d", r.lastTerminology.Batches)
}
if r.lastTerminology.BadLines == 0 {
t.Fatalf("a batch answering for a term it was NOT shown must refuse those lines: %+v", r.lastTerminology)
}
consolidatedAll := r.lastTerminology.Consolidated
r.Close()
// Same fixture, budget sized from the role's OWN per-batch projection so exactly one call fits.
perBatch := r.lastTerminology.EstimateUSD / float64(r.lastTerminology.Batches)
budget := perBatch * 1.5
bookPath2 := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, batchRunes: 400, budgetUSD: budget})
r2 := newVerifyRunner(t, bookPath2)
defer r2.Close()
runToSignatureStop(t, r2)
if r2.lastTerminology.Batches < 2 {
t.Fatalf("the fixture must still batch under a budget, got %d", r2.lastTerminology.Batches)
}
// The bound is real: the ceiling in the config is not overshot by the size of the last permitted call.
if r2.lastTerminology.CostUSD > budget {
t.Fatalf("the role spent past its budget: %v > %v", r2.lastTerminology.CostUSD, budget)
}
if r2.lastTerminology.Consolidated >= consolidatedAll {
t.Fatalf("a budget that stops after the first batch must leave the rest unconsolidated: %d vs %d",
r2.lastTerminology.Consolidated, consolidatedAll)
}
}
// TestTerminologistResumeIsFree pins the durable contour: a second run replays the role's calls from their
// checkpoints for $0 and re-derives a byte-identical signature map. Without it the owner would pay for the
// whole bank again on every re-run of a stop they have not yet signed — and the stop is BY DESIGN re-run.
func TestTerminologistResumeIsFree(t *testing.T) {
rec := &reqRec{}
terminologyCalls := 0
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
terminologyCalls++
return "方源\tФан Юань", "stop"
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
r1 := newVerifyRunner(t, bookPath)
runToSignatureStop(t, r1)
first := readSignatureMap(t, r1)
callsAfterRun1 := terminologyCalls
spent1, _ := r1.Store.RoleSpentUSD("test-book", roleTerminologist)
r1.Close()
r2 := newVerifyRunner(t, bookPath)
defer r2.Close()
runToSignatureStop(t, r2)
if terminologyCalls != callsAfterRun1 {
t.Fatalf("a resumed stop must replay the terminology calls, not re-buy them: %d -> %d", callsAfterRun1, terminologyCalls)
}
if r2.lastTerminology.CostUSD != 0 {
t.Fatalf("the resumed run must pay $0 for terminology, got %v", r2.lastTerminology.CostUSD)
}
spent2, _ := r2.Store.RoleSpentUSD("test-book", roleTerminologist)
if spent2 != spent1 {
t.Fatalf("a resume must not add terminology spend: %v -> %v", spent1, spent2)
}
if second := readSignatureMap(t, r2); second != first {
t.Fatalf("the signature map must be byte-stable across a $0 resume:\n--- run1 ---\n%s\n--- run2 ---\n%s", first, second)
}
}
// TestTerminologistReverseSectionReachesTheMap pins the two-way join's reverse half: a term ONLY the
// banknote saw — the class the miner structurally cannot emit, because a cluster touching a seed surface
// is suppressed as an alias-of-existing — must reach the owner instead of being dropped. This is the
// coverage hole the phase-1 measurement put at 3%.
func TestTerminologistReverseSectionReachesTheMap(t *testing.T) {
rec := &reqRec{}
// 青茅山 IS in the source and IS proposed by the banknote, but the miner does not emit it: the seed
// already holds it… so seed it with something else and let 青茅山 be the banknote-only surface.
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "青茅山\tгора Цинмао\n方源\tФан Юань", "stop"
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
r := newVerifyRunner(t, bookPath)
defer r.Close()
runToSignatureStop(t, r)
yamlMap := readSignatureMap(t, r)
if !strings.Contains(yamlMap, "src: 青茅山") {
t.Fatalf("a banknote-only surface that OCCURS in the source must reach the sign map:\n%s", yamlMap)
}
if !strings.Contains(yamlMap, "banknote-only candidate") {
t.Fatalf("the reverse section must be labelled as such — its provenance is the whole point:\n%s", yamlMap)
}
if r.lastTerminology.Reverse == 0 {
t.Fatalf("the reverse section must be counted: %+v", r.lastTerminology)
}
}
// TestTerminologistOffIsByteIdenticalAndFree is the opt-in guarantee: with the gate off the stop takes
// exactly its previous path — no provider call for the role, no spend, and a delta whose rows are the
// WHICH-only/auto shape. A feature that quietly changed an existing book's artifacts would re-open every
// signature the owner already gave.
func TestTerminologistOffIsByteIdenticalAndFree(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
t.Error("the terminologist must not be called with the gate off")
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{})
r := newVerifyRunner(t, bookPath)
defer r.Close()
runToSignatureStop(t, r)
yamlMap := readSignatureMap(t, r)
if strings.Contains(yamlMap, "status: draft") || strings.Contains(yamlMap, "CONSOLIDATED") {
t.Fatalf("with the gate off no row may be lifted out of the auto mode:\n%s", yamlMap)
}
if strings.Contains(yamlMap, "src: 青茅山") {
t.Fatalf("with the gate off the reverse section must not be emitted:\n%s", yamlMap)
}
if spent, _ := r.Store.RoleSpentUSD("test-book", roleTerminologist); spent != 0 {
t.Fatalf("a disabled role must cost $0, got %v", spent)
}
}
// TestBankStopDefaultIsAutoContinue pins the ratified DEFAULT (D39.42 п.5, owner's words: «просто как
// намайнит и закончит — шла в редактуру»). Without --verify-bank a non-empty bank does NOT halt the run:
// the artifacts are written, the edit wave runs, and the book finishes. Before pack-20 the same input
// stopped, and no test in the suite would have gone red for the change — which is precisely why this one
// exists (risk 1 of the phase-1 design: a silent default regression).
func TestBankStopDefaultIsAutoContinue(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()
r := newRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{})) // NO VerifyBank
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatalf("the default must not stop, got %v", err)
}
if !editSeen || res == nil || len(res.Chunks) == 0 {
t.Fatalf("the run must continue into the edit wave (edit_seen=%v res=%+v)", editSeen, res)
}
if r.lastMinedCount == 0 {
t.Fatal("test premise broken: the bank must be non-empty, else auto-continue proves nothing")
}
// The artifacts are still written — the owner can sign LATER, which is what makes the mode safe.
if _, serr := os.Stat(r.signatureMapPath()); serr != nil {
t.Fatalf("the auto mode must still record what it decided: %v", serr)
}
if _, serr := os.Stat(r.bankStopTablePath()); serr != nil {
t.Fatalf("the auto mode must still write the bank table: %v", serr)
}
}
// TestBankStopWritesTheRichTable pins D39.36's «стоп с таблицей»: the stop carries the columns the owner
// was promised — src · proposed dst · frequency · variant spread · evidence · contexts — and the FULL
// table lands in a sidecar (the stdout view is capped; emitRankCap is 200).
func TestBankStopWritesTheRichTable(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "方源\tФан Юань", "stop"
}
return "Фан Юань пришёл." + "\n" + bankSeparator +
"\n方源\tФан Юань\tname\n方源\tФань Юань\tname", "stop"
})
defer srv.Close()
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true}))
defer r.Close()
stop := runToSignatureStop(t, r)
if len(stop.Rows) == 0 {
t.Fatal("the stop must carry the table, not just a count")
}
if stop.TablePath != r.bankStopTablePath() {
t.Fatalf("stop table path %q != %q", stop.TablePath, r.bankStopTablePath())
}
var fy *BankStopRow
for i := range stop.Rows {
if stop.Rows[i].Src == "方源" {
fy = &stop.Rows[i]
}
}
if fy == nil {
t.Fatalf("方源 missing from the table: %+v", stop.Rows)
}
if fy.Dst != "Фан Юань" || fy.Freq == 0 || fy.Origin != string(terminology.OriginBoth) {
t.Fatalf("row = %+v, want the consolidated dst, a frequency and the both-channels origin", *fy)
}
if len(fy.Contexts) == 0 || len(fy.Evidence) == 0 {
t.Fatalf("the row must carry its source contexts and its evidence: %+v", *fy)
}
raw, err := os.ReadFile(stop.TablePath)
if err != nil {
t.Fatalf("the sidecar must exist: %v", err)
}
for _, want := range []string{"方源", "Фан Юань", "origin=", "freq=", "spread=", "ctx:"} {
if !strings.Contains(string(raw), want) {
t.Fatalf("the sidecar must carry %q:\n%s", want, raw)
}
}
}
// TestBankStopNeverFiresInADraftOnlyPipeline pins S16. The stop's own contract is "before the edit wave";
// in a draft-only pipeline there is none, and stopping there discarded the assembled BookResult of a
// draft wave that had already been paid for.
func TestBankStopNeverFiresInADraftOnlyPipeline(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(string) (string, string) {
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{})
// Drop the edit stage: the draft IS the shipping output.
pp := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(pp)
if err != nil {
t.Fatal(err)
}
var kept []string
for _, ln := range strings.Split(string(raw), "\n") {
if !strings.Contains(ln, "name: edit,") {
kept = append(kept, ln)
}
}
writeFile(t, pp, strings.Join(kept, "\n"))
r := newVerifyRunner(t, bookPath) // asks to stop — and there is nothing to stop before
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatalf("a draft-only pipeline must not stop at the bank boundary, got %v", err)
}
if res == nil || len(res.Chunks) == 0 || res.Chunks[0].FinalText == "" {
t.Fatalf("the draft-only BookResult must survive the boundary: %+v", res)
}
if _, serr := os.Stat(r.signatureMapPath()); serr != nil {
t.Fatalf("the artifacts must still be written: %v", serr)
}
}
// TestVerifyBankFailsLoudWhenMiningIsImpossible: asking to verify a bank a book cannot mine must be a
// loud refusal, not a clean exit. A silent continue would return exit 0 for a verification that never
// happened — the operator would read "no new terms" where the truth is "no detector".
func TestVerifyBankFailsLoudWhenMiningIsImpossible(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
r := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})) // no langpack, no contrast
defer r.Close()
r.VerifyBank = true
_, err := r.TranslateBook(context.Background())
if err == nil {
t.Fatal("--verify-bank on a book that cannot mine must fail loud, not exit clean")
}
for _, want := range []string{"--verify-bank", "langpack_root", "mining.contrast_path"} {
if !strings.Contains(err.Error(), want) {
t.Fatalf("the refusal must name %q so the operator knows what is missing: %v", want, err)
}
}
// Without the flag the same book runs normally — the refusal is scoped to the explicit request.
r.VerifyBank = false
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatalf("an unmined book must still translate without the flag: %v", err)
}
}
// TestTerminologyIsNotSnapshotFolded pins the claim config.TerminologyGate makes. The role writes a FILE
// the owner signs; it puts nothing into the bank, so it reaches the wire only through mined_delta →
// memoryVersion → the edit-wave snapshot, which is folded already. Folding the gate itself would re-bill
// a whole wave for enabling a step that changes no existing checkpoint — the mistake Mining.ContrastPath
// documents avoiding. If this ever needs to change, it must change LOUDLY.
func TestTerminologyIsNotSnapshotFolded(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
r := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0}))
defer r.Close()
before := map[wave]string{}
for _, w := range []wave{waveDraft, waveEdit} {
id, _, err := r.snapshotIDForWave(w)
if err != nil {
t.Fatal(err)
}
before[w] = id
}
r.Pipeline.Gates.Terminology = config.TerminologyGate{
Enabled: true, Model: "fake-model", BudgetUSD: 1, BatchRunes: 999, KWICPerTerm: 7, KWICWidth: 11,
}
for _, w := range []wave{waveDraft, waveEdit} {
id, _, err := r.snapshotIDForWave(w)
if err != nil {
t.Fatal(err)
}
if id != before[w] {
t.Fatalf("enabling terminology moved the %v snapshot (%s → %s) — that re-bills a paid wave for a step that changes no checkpoint", w, before[w][:12], id[:12])
}
}
}
// termBlock returns the YAML block of one term from a signature map (from its `- src:` line to the next).
func termBlock(t *testing.T, yamlMap, src string) string {
t.Helper()
lines := strings.Split(yamlMap, "\n")
start := -1
for i, ln := range lines {
if strings.HasPrefix(strings.TrimSpace(ln), "- src: ") && strings.Contains(ln, src) {
start = i
continue
}
if start >= 0 && strings.HasPrefix(strings.TrimSpace(ln), "- src: ") {
return strings.Join(lines[start:i], "\n")
}
}
if start < 0 {
t.Fatalf("term %q not found in the signature map:\n%s", src, yamlMap)
}
return strings.Join(lines[start:], "\n")
}
// trustGateSeed puts an APPROVED short key inside a lower-trust LONGER one: reading 四代族長 at ch1, the
// draft key wants to eat the nested approved 族長 by longest-match. The disposition gate refuses (D39
// layer 4) — the draft is editor-excluded, so eating the approved term would leave the reader with
// nothing AND the drop would be invisible.
const trustGateSeed = `
terms:
- src: 族長
dst: глава клана
status: approved
decl: { invariant: true, forms: ["глава клана"] }
- src: 四代族長
dst: четвёртый глава
status: draft
decl: { invariant: true, forms: ["четвёртый глава"] }
`
// TestTrustGateDetailPersists pins chunkrun.go:131-139 — the third un-pinned call-site of the same class
// (§6 of the phase-1 design). The REFUSED suppression is the term-drift code root made loud: if the
// detail stops being written, the refusal degrades back to the near-silent drop research/13 §7 describes,
// and no other test notices because the count alone says nothing about WHICH pair collided.
func TestTrustGateDetailPersists(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
source: "四代族長が来た。族長は強い。", glossarySeed: trustGateSeed, regenerate: 0,
})
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
rs, err := r.Store.GetRetrievalState("test-book", 1, 0)
if err != nil || rs == nil {
t.Fatalf("no retrieval_state: %v", err)
}
if rs.NTrustGatedSuppress != 1 {
t.Fatalf("want exactly one refused suppression, got %d", rs.NTrustGatedSuppress)
}
// The count is not enough: the human needs the PAIR to reconcile the seed. The detail carries the
// bank's NORMALIZED keys (the seed is written in traditional forms; the bank folds trad→simp), so a
// reader looking for their own spelling is looking at the matcher's spelling — worth pinning as such.
if !strings.Contains(rs.TrustGateDetail, "四代族长") || !strings.Contains(rs.TrustGateDetail, "族长") {
t.Fatalf("the detail must name the suppressor→protected pair, got %q", rs.TrustGateDetail)
}
if !strings.Contains(rs.TrustGateDetail, string(membank.Ambiguous)) ||
!strings.Contains(rs.TrustGateDetail, string(membank.Confirmed)) {
t.Fatalf("the detail must carry both dispositions (that is WHY it was refused), got %q", rs.TrustGateDetail)
}
}
// TestMiningStopRejectClearsDelta pins mining.go:64-68 end-to-end: the owner's second verb. A term that was
// DECLINED must leave the delta, and once every proposed term is declined the stop clears and the run goes
// on to the edit wave. Without it a declined term re-fires the stop forever (R1-FL-B livelock).
func TestMiningStopRejectClearsDelta(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()
// First: WITHOUT the reject list, learn which terms the stop actually proposes.
probe := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{}))
stop := runToSignatureStop(t, probe)
proposed := readSignatureMap(t, probe)
probe.Close()
if editSeen {
t.Fatal("the probe run must have stopped before the edit wave")
}
// Now decline EVERY proposed term; the delta must empty and the run must continue.
var rejects strings.Builder
rejects.WriteString("rejects:\n")
for _, line := range strings.Split(proposed, "\n") {
if s, ok := strings.CutPrefix(strings.TrimSpace(line), "- src: "); ok {
rejects.WriteString(" - src: " + s + "\n")
}
}
if strings.Count(rejects.String(), "- src:") != stop.Terms {
t.Fatalf("test premise broken: %d rejects for %d proposed terms:\n%s", strings.Count(rejects.String(), "- src:"), stop.Terms, proposed)
}
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{rejects: rejects.String()})
r := newVerifyRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatalf("a fully-declined delta must clear the stop, got %v", err)
}
if !editSeen || res == nil || len(res.Chunks) == 0 {
t.Fatalf("the run must continue into the edit wave after every term is declined (edit_seen=%v, res=%+v)", editSeen, res)
}
if _, err := os.Stat(r.signatureMapPath()); err == nil {
t.Fatal("a cleared stop must not leave a stale signature map behind")
}
}
// --- the AUTO wire (pack-20 / D39.42 п.3) -------------------------------------------------------------
// TestAutoWireDeliversUnsignedTermsToTheEditor is the pack's other half. Without --verify-bank the run
// does not stop — and the whole question is what the editor then receives. The owner's decision: the
// unsigned rendering DOES reach it, in its own labelled section, marked ⟨проверить⟩, never inside the
// canon list. Before pack-20 a mined+auto row was inert on BOTH wires (excluded from the draft's base
// bank by Source, filtered from the editor block by disposition), so the auto mode delivered nothing at
// all — the structural dead end the phase-1 sync measured.
func TestAutoWireDeliversUnsignedTermsToTheEditor(t *testing.T) {
rec := &reqRec{}
var editBody string
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "方源\tФан Юань", "stop"
}
if isEditBody(body) {
editBody = body
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
r := newRunner(t, bookPath) // AUTO mode: no VerifyBank
defer r.Close()
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
if editBody == "" {
t.Fatal("the edit wave must have run")
}
tx := lang.InjectionTextsFor("ru")
if !strings.Contains(editBody, tx.EditorUnverifiedHeader) {
t.Fatalf("the editor must receive the unsigned rows under their OWN header:\n%s", editBody)
}
if !strings.Contains(editBody, "Фан Юань") {
t.Fatalf("the consolidated rendering must reach the editor:\n%s", editBody)
}
// …and NOT as canon: the unsigned line must sit after the unverified header, marked.
_, unverified, _ := strings.Cut(editBody, tx.EditorUnverifiedHeader)
if !strings.Contains(unverified, "Фан Юань") {
t.Fatalf("the unsigned rendering leaked into the canon section:\n%s", editBody)
}
// The bank now durably holds it, unsigned — the owner can still sign it later.
rows, err := r.Store.GlossaryForBook("test-book")
if err != nil {
t.Fatal(err)
}
found := false
for _, e := range rows {
if e.Src == "方源" {
found = true
if e.Status == "approved" {
t.Fatalf("an auto-mode row must NEVER be approved: %+v", e)
}
if e.Source != "mined" {
t.Fatalf("an auto row must be base-excluded (Source:mined), got %q", e.Source)
}
}
}
if !found {
t.Fatalf("the consolidated term must be in the bank: %+v", rows)
}
if _, serr := os.Stat(r.autoBankPath()); serr != nil {
t.Fatalf("the auto-bank artifact must persist for the next run: %v", serr)
}
}
// TestAutoWireIsDeterministicAndDoesNotMoveTheDraftWave pins the money contract of the auto mode: the
// unsigned rows land in the ENRICHED bank only, so the draft wave's snapshot and injection are untouched
// and a second run replays every draft for $0. This is the «re-paid ONCE» invariant, tested on the path
// that now actually adds rows mid-run.
func TestAutoWireIsDeterministicAndDoesNotMoveTheDraftWave(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "方源\tФан Юань", "stop"
}
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
draft1, _, err := r1.snapshotIDForWave(waveDraft)
if err != nil {
t.Fatal(err)
}
autoBank1, err := os.ReadFile(r1.autoBankPath())
if err != nil {
t.Fatal(err)
}
calls1 := rec.count()
r1.Close()
r2 := newRunner(t, bookPath)
defer r2.Close()
res2, err := r2.TranslateBook(context.Background())
if err != nil {
t.Fatalf("the second run must resume cleanly, got %v", err)
}
draft2, _, err := r2.snapshotIDForWave(waveDraft)
if err != nil {
t.Fatal(err)
}
if draft2 != draft1 {
t.Fatalf("the auto rows moved the DRAFT-wave snapshot (%s → %s) — that re-bills the whole draft wave", draft1[:12], draft2[:12])
}
if res2.TotalUSD != 0 {
t.Fatalf("a resumed auto-mode run must be $0, got %v", res2.TotalUSD)
}
if rec.count() != calls1 {
t.Fatalf("the resume must not re-call the provider: %d -> %d", calls1, rec.count())
}
autoBank2, err := os.ReadFile(r2.autoBankPath())
if err != nil {
t.Fatal(err)
}
if string(autoBank2) != string(autoBank1) {
t.Fatalf("the auto-bank must be byte-stable across runs:\n--- 1 ---\n%s\n--- 2 ---\n%s", autoBank1, autoBank2)
}
}
// TestAutoBankRejectAppliesOnLoad pins risk 3: a term the owner DECLINED must not re-enter the bank
// through the engine's own file. Until pack-20 the reject set was consulted only at EMISSION, so an
// accumulated auto row survived the decline — «reject-set works in both modes» has to hold on the way IN.
func TestAutoBankRejectAppliesOnLoad(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "方源\tФан Юань", "stop"
}
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
// Run once WITHOUT rejects so the auto-bank file exists and holds 方源.
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
autoBank, err := os.ReadFile(r1.autoBankPath())
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(autoBank), "方源") {
t.Fatalf("test premise broken: the auto-bank must hold 方源:\n%s", autoBank)
}
dbPath := r1.Book.ProjectDB
r1.Close()
// Now the owner declines it, and the SAME project is re-run: the row must not come back.
dir := filepath.Dir(bookPath)
writeFile(t, filepath.Join(dir, "mined-rejects.yaml"), "rejects:\n - src: 方源\n")
raw, err := os.ReadFile(bookPath)
if err != nil {
t.Fatal(err)
}
writeFile(t, bookPath, string(raw)+"\nmined_rejects: mined-rejects.yaml\n")
_ = dbPath
// Dropping a row from the bank legitimately moves the edit-wave snapshot, so the second run refuses
// until the operator accepts the re-pin and the re-payment. That refusal is the CORRECT loud behaviour
// (and its own small proof that an unsigned row is now inside the consent contour) — assert it, then
// consent and continue.
r2 := newRunner(t, bookPath)
if _, err := r2.TranslateBook(context.Background()); err == nil {
t.Fatal("dropping a bank row must move the edit-wave snapshot and stop the run for consent")
}
r2.Close()
r3 := newRunner(t, bookPath)
defer r3.Close()
r3.Resnapshot = true
r3.AcceptRebill = RebillConsent{Given: true}
if _, err := r3.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
rows, err := r3.Store.GlossaryForBook("test-book")
if err != nil {
t.Fatal(err)
}
for _, e := range rows {
if e.Src == "方源" && e.Status != "approved" {
t.Fatalf("a DECLINED term re-entered the bank through the auto-bank file: %+v", e)
}
}
if body, rerr := os.ReadFile(r3.autoBankPath()); rerr == nil && strings.Contains(string(body), "src: 方源") {
t.Fatalf("the declined term must not be re-emitted into the auto-bank either:\n%s", body)
}
}
// TestAutoBankRefusesAnApprovedRow: the engine's file is the engine's word. A row in it claiming
// `approved` would be a signature nobody gave, so the load refuses rather than silently trusting it.
func TestAutoBankRefusesAnApprovedRow(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{})
r := newRunner(t, bookPath)
writeFile(t, r.autoBankPath(), "terms:\n - src: 方源\n dst: Фан Юань\n status: approved\n")
r.Close()
r2, err := NewRunner(bookPath, obs.NewLogger())
if err == nil {
defer r2.Close()
_, err = r2.TranslateBook(context.Background())
}
if err == nil || !strings.Contains(err.Error(), "approved") {
t.Fatalf("an `approved` row in the engine's own file must be refused loudly, got %v", err)
}
}
// TestUnsignedRowsDoNotSelfExcludeTheMiner pins risk 2. Once the auto mode has written its rows, they
// live in the bank — and to the miner a bank row looks exactly like a seeded term. If they counted as
// seed surfaces the delta would empty on the next run and --verify-bank would silently stop firing on
// terms nobody ever reviewed: the operator would read "no new terms" as "the bank is signed".
func TestUnsignedRowsDoNotSelfExcludeTheMiner(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "方源\tФан Юань", "stop"
}
if isEditBody(body) {
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
firstCount := r1.lastMinedCount
r1.Close()
if firstCount == 0 {
t.Fatal("test premise broken: the first run must mine something")
}
// Second run, now WITH the verification flag: the same unreviewed terms must still be offered.
r2 := newVerifyRunner(t, bookPath)
defer r2.Close()
stop := runToSignatureStop(t, r2)
if stop.Terms != firstCount {
t.Fatalf("the unsigned bank swallowed its own proposals: %d → %d terms", firstCount, stop.Terms)
}
}
// TestUnverifiedChannelIsMeasuredOnTheWireThatShowsIt closes the P4 finding: the ambiguous counter used
// to be collected on the EDITOR wire while the editor block was CONFIRMED-only — it measured deviation
// from something the editor was never shown. Now the editor does see the unsigned section, and the
// shown/followed pair says what the model did with it. Neither number gates anything.
func TestUnverifiedChannelIsMeasuredOnTheWireThatShowsIt(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return "方源\tФан Юань", "stop"
}
if isEditBody(body) {
return "Фан Юань пришёл к горе.", "stop" // the editor FOLLOWED the unsigned rendering
}
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})
r := newRunner(t, bookPath)
defer r.Close()
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
if res.Flagged != 0 {
t.Fatalf("an unsigned row must never flag a unit (it is a candidate the model may reject): %+v", res)
}
rs, err := r.Store.GetRetrievalState("test-book", 1, 0)
if err != nil || rs == nil {
t.Fatalf("no retrieval_state: %v", err)
}
if rs.NUnverifiedShown == 0 {
t.Fatalf("the unsigned row fired in this unit — it must be counted as SHOWN: %+v", rs)
}
if rs.NUnverifiedFollowed == 0 || rs.NUnverifiedFollowed > rs.NUnverifiedShown {
t.Fatalf("the editor used one of the proposed renderings: followed must be in (0, shown]: %+v", rs)
}
// The unsigned row the editor did NOT use travels in the detail (observability), never in the count.
if !strings.Contains(rs.PostcheckDetail, "ambiguous") {
t.Fatalf("an unsigned deviation must stay visible in the detail: %q", rs.PostcheckDetail)
}
if rs.NPostcheckMiss != 0 {
t.Fatalf("an unsigned row must never enter the CONFIRMED-miss count: %+v", rs)
}
q, err := r.QualityReport()
if err != nil {
t.Fatal(err)
}
if q.UnsignedBankTerms == 0 || q.UnverifiedShown == 0 {
t.Fatalf("the unsigned-bank exposure must be visible in the report: %+v", q)
}
}
// TestLoadAutoBankFiltersRejectsAndCollisions pins the two load-time guards directly, because the
// end-to-end path can hide them: the mining stop rewrites the auto-bank on every run, so by the time a
// finished run is inspected a declined row is gone for a second reason. These filters matter BEFORE that
// — seedGlossary loads the file at the start of the run, and its rows are already in the bank when the
// re-payment projection and (in --verify-bank mode) the stop itself look at it.
func TestLoadAutoBankFiltersRejectsAndCollisions(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{
rejects: "rejects:\n - src: 花家\n",
})
r := newRunner(t, bookPath)
defer r.Close()
writeFile(t, r.autoBankPath(), "terms:\n"+
" - src: 方源\n dst: Фан Юань (авто)\n status: draft\n"+
" - src: 花家\n dst: Дом Хуа\n status: draft\n"+
" - src: 青茅山\n dst: гора Цинмао\n status: draft\n")
signed := []store.GlossaryEntry{{Src: "方源", Dst: "Фан Юань", Status: "approved", Source: "seed"}}
rows, dropped, err := r.loadAutoBank(signed)
if err != nil {
t.Fatal(err)
}
if len(rows) != 1 || rows[0].Src != "青茅山" {
t.Fatalf("only the clean row may enter the bank, got %+v", rows)
}
if rows[0].Source != "mined" {
t.Fatalf("an engine row must be base-excluded (Source:mined), got %q", rows[0].Source)
}
joined := strings.Join(dropped, " | ")
if !strings.Contains(joined, "花家") || !strings.Contains(joined, "declined") {
t.Fatalf("the DECLINED row must be dropped and said out loud: %q", joined)
}
if !strings.Contains(joined, "方源") || !strings.Contains(joined, "signed") {
t.Fatalf("the row colliding with a SIGNED term must be dropped and said out loud: %q", joined)
}
}
// TestAutoBankKeyCollisionDoesNotCrashTheRun is risk 4 end-to-end: an auto row whose UNIQUE key
// (src, sense, since_ch, until_ch) is already held by a signed term would make the flat INSERT in
// ReplaceGlossary hit the constraint and abort a PAID run mid-flight. The engine's own proposal must
// never be able to do that — the signed term wins and the run goes on.
func TestAutoBankKeyCollisionDoesNotCrashTheRun(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
const seed = "terms:\n - src: 方源\n dst: Фан Юань\n status: approved\n decl: { invariant: true, forms: [\"Фан Юань\"] }\n"
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{glossarySeed: seed})
r := newRunner(t, bookPath)
// The engine proposes a DIFFERENT rendering for a term the owner already signed, on the same key.
writeFile(t, r.autoBankPath(), "terms:\n - src: 方源\n dst: Источник Фана\n status: draft\n")
res, err := r.TranslateBook(context.Background())
if err != nil {
t.Fatalf("a colliding engine proposal must not abort a paid run: %v", err)
}
if res == nil || len(res.Chunks) == 0 {
t.Fatalf("the run must complete: %+v", res)
}
rows, err := r.Store.GlossaryForBook("test-book")
if err != nil {
t.Fatal(err)
}
for _, e := range rows {
if e.Src == "方源" && e.Dst != "Фан Юань" {
t.Fatalf("the SIGNED rendering must win over the engine's proposal, got %+v", e)
}
}
r.Close()
}
// TestUnsignedConflictIsReportedByTheRun wires S14's diagnostic to the run itself: a bank that holds an
// approved term AND an unsigned proposal contradicting it on the same firing surface must say so out
// loud. The check is a WARNING by design — the unsigned row is engine-produced, and aborting a paid run
// over the engine's own proposal would be a self-inflicted outage — so the assertion is on the log.
func TestUnsignedConflictIsReportedByTheRun(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
const seed = "terms:\n - src: 方源\n dst: Фан Юань\n status: approved\n decl: { invariant: true, forms: [\"Фан Юань\"] }\n"
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{glossarySeed: seed})
r := newRunner(t, bookPath)
defer r.Close()
// A DIFFERENT unsigned rendering of a surface the owner already signed, on its own key (a different
// spoiler window, so ReplaceGlossary accepts both rows and BOTH are injected).
writeFile(t, r.autoBankPath(), "terms:\n - src: 方源\n dst: Источник Фана\n status: draft\n since_ch: 2\n")
var logs bytes.Buffer
r.Log = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelWarn}))
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
out := logs.String()
if !strings.Contains(out, "contradicts an approved term") {
t.Fatalf("the approved-vs-unsigned contradiction must be reported (S14):\n%s", out)
}
if !strings.Contains(out, "Источник Фана") {
t.Fatalf("the report must name the contradicting rendering:\n%s", out)
}
}
// TestUnverifiedChannelOnADraftOnlyPipeline covers the OTHER writer of the same counters. In a two-stage
// book the shipping wire is the editor, so the edit unit records them; in a draft-only book the draft IS
// the shipping output and the translator block is where the unsigned row is shown. Both paths must
// record, or the channel is silently half-blind depending on the pipeline shape.
func TestUnverifiedChannelOnADraftOnlyPipeline(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(string) (string, string) {
return "Фан Юань пришёл к горе.", "stop" // follows the unsigned rendering
})
defer srv.Close()
const seed = "terms:\n - src: 方源\n dst: Фан Юань\n status: draft\n decl: { invariant: true, forms: [\"Фан Юань\"] }\n"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
source: miningStopSource, glossarySeed: seed, regenerate: 0,
})
// Draft-only: drop the edit stage so the draft is the shipping text.
pp := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(pp)
if err != nil {
t.Fatal(err)
}
var kept []string
for _, ln := range strings.Split(string(raw), "\n") {
if !strings.Contains(ln, "name: edit,") {
kept = append(kept, ln)
}
}
writeFile(t, pp, strings.Join(kept, "\n"))
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
rs, err := r.Store.GetRetrievalState("test-book", 1, 0)
if err != nil || rs == nil {
t.Fatalf("no retrieval_state: %v", err)
}
if rs.NUnverifiedShown == 0 || rs.NUnverifiedFollowed == 0 {
t.Fatalf("the draft wire showed the unsigned row and the model used it — both must be counted: %+v", rs)
}
if rs.NPostcheckMiss != 0 {
t.Fatalf("an unsigned row must never enter the CONFIRMED-miss count: %+v", rs)
}
}
// --- pointwise re-edit by key (pack-20 point 5 / D39.42 п.5) ------------------------------------------
// TestPointwiseReEditOnlyPaysForTheUnitsTheTermTouches is the money proof of the pack's last point. The
// owner signs ONE term after the edit wave is paid for. Before pack-20 that moved the edit-wave snapshot
// and re-bought EVERY unit of the wave — the whole book for one word. Now the units whose injected bytes
// did not change are re-pinned for $0, and only the chapter where the term actually occurs is re-edited.
func TestPointwiseReEditOnlyPaysForTheUnitsTheTermTouches(t *testing.T) {
rec := &reqRec{}
editCalls := 0
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
editCalls++
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
return "ЧЕРНОВИК ПЕРЕВОДА", "stop"
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
regenerate: 0,
epub: []chunktest.Chapter{
{ID: "c1", Href: "ch1.xhtml", Body: `<p>方源来到青茅山。方源很强。</p>`},
{ID: "c2", Href: "ch2.xhtml", Body: `<p>花家很大。花家的人很多。</p>`},
},
spine: []string{"c1", "c2"},
})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(context.Background()); err != nil {
t.Fatal(err)
}
if editCalls != 2 {
t.Fatalf("test premise broken: want one edit per chapter, got %d", editCalls)
}
r1.Close()
// The owner signs a term that occurs ONLY in chapter 2.
dir := filepath.Dir(bookPath)
writeFile(t, filepath.Join(dir, "mined-delta.yaml"),
"terms:\n - src: 花家\n dst: дом Хуа\n status: approved\n decl: { invariant: true, forms: [\"дом Хуа\"] }\n")
raw, err := os.ReadFile(bookPath)
if err != nil {
t.Fatal(err)
}
writeFile(t, bookPath, string(raw)+"\nmined_delta: mined-delta.yaml\n")
// The ESTIMATE first: it must name ONE unit, not the whole wave.
rp := newRunner(t, bookPath)
if err := rp.seedGlossary(context.Background()); err != nil {
t.Fatal(err)
}
chunks, err := rp.bookChunks()
if err != nil {
t.Fatal(err)
}
statuses, err := rp.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
proj, err := rp.projectRebill(statuses, chunks)
if err != nil {
t.Fatal(err)
}
if proj.Rows != 1 {
t.Fatalf("the estimate must name only the unit the term touches, got %d rows (repinned=%d)", proj.Rows, proj.Repinned)
}
if proj.Repinned == 0 {
t.Fatalf("the untouched unit must be counted as re-pinned, not silently omitted: %+v", proj)
}
rp.Close()
// …and the run must charge exactly that.
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true
r2.AcceptRebill = RebillConsent{Given: true}
editBefore := editCalls
res2, err := r2.TranslateBook(context.Background())
if err != nil {
t.Fatal(err)
}
if got := editCalls - editBefore; got != 1 {
t.Fatalf("signing a term that occurs in ONE chapter must re-edit ONE unit, got %d edit call(s)", got)
}
if res2.TotalUSD == 0 || res2.TotalUSD > 2*fakeCallUSD {
t.Fatalf("the run must pay for exactly the re-edited unit, got $%v", res2.TotalUSD)
}
// The re-pinned unit's row now carries the CURRENT snapshot, so the next run is a plain $0 resume.
editSnap, _, err := r2.snapshotIDForWave(waveEdit)
if err != nil {
t.Fatal(err)
}
cs, err := r2.Store.GetChunkStatus("test-book", 1, 0, "edit")
if err != nil || cs == nil {
t.Fatalf("no edit row for chapter 1: %v", err)
}
if cs.SnapshotID != editSnap {
t.Fatalf("the re-pinned row must carry the current snapshot, got %.12s want %.12s", cs.SnapshotID, editSnap)
}
}
// TestRepinRefusesAnyMoveThatIsNotBankOnly guards the predicate's boundary. The $0 re-pin is sound ONLY
// because every wire-shaping input is provably unchanged; the moment anything else moves — a prompt, a
// model, a gate — a stored result may no longer be what this config would produce, and the answer must
// go back to "re-pay". The classifier is deliberately structural (key-by-key over the payload), so a
// component folded in the future counts as a difference without anyone remembering to update it.
func TestRepinRefusesAnyMoveThatIsNotBankOnly(t *testing.T) {
const base = `{"brief_hash":"b","memory_version":"m1","render_format_version":"r","postcheck_gate":false}`
cases := []struct {
name string
now string
want snapshotMoveKind
}{
{"bank only", `{"brief_hash":"b","memory_version":"m2","render_format_version":"r","postcheck_gate":false}`, moveBankOnly},
{"prompt moved too", `{"brief_hash":"b2","memory_version":"m2","render_format_version":"r","postcheck_gate":false}`, moveOther},
{"gate flipped", `{"brief_hash":"b","memory_version":"m2","render_format_version":"r","postcheck_gate":true}`, moveOther},
{"renderer moved", `{"brief_hash":"b","memory_version":"m2","render_format_version":"r2","postcheck_gate":false}`, moveOther},
{"new component folded", `{"brief_hash":"b","memory_version":"m2","render_format_version":"r","postcheck_gate":false,"new_axis":"x"}`, moveOther},
{"component dropped", `{"brief_hash":"b","memory_version":"m2","postcheck_gate":false}`, moveOther},
{"nothing moved", base, moveOther},
{"unreadable", `not json`, moveUnknown},
{"absent", ``, moveUnknown},
}
for _, c := range cases {
if got := classifySnapshotMove(base, c.now); got != c.want {
t.Errorf("%s: classify = %v, want %v", c.name, got, c.want)
}
}
}