textmachine/backend/internal/pipeline/miningstop_join_test.go

1925 lines
89 KiB
Go
Raw Permalink 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"
"reflect"
"strings"
"testing"
"textmachine/backend/internal/chunk"
"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 delta holding never-presented terms, before the edit wave, and the edit wave
// never runs (the D39.144 flag model — see mining.go);
// - 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 with nothing decided goes ON past the stop, rewrites the same map byte-for-byte, and the
// resume fast-path never touches the raw checkpoint, so a lost banknote_detail would silently empty
// the WHAT column;
// - the owner's reject list empties the delta (a declined surface never enters a map again).
//
// 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
classify bool // enable the §2 classifier phase (needs terminology) with a fixture prompt
// classifyBudgetUSD is gates.terminology.classify_budget_usd; 0 = 1.0, which is effectively unbounded
// for this corpus. It exists so a fixture can cut the CLASSIFY pass while leaving the render pass
// whole — the state where «the bank is partial» and «the term types are unrefined» come apart, and the
// only one that can tell a warning about the bank from a warning about the classifier.
classifyBudgetUSD float64
// regenerate is gates.terminology.regenerate — how many times a bank batch may be RE-ASKED when the
// reply comes back truncated or empty. 0 OMITS the key, so every pre-existing fixture's pipeline.yaml
// stays byte-identical and its snapshot id does not move.
regenerate int
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"
// srcsOfProposals and srcsOfRows render the two orders side by side when they disagree: a failure that
// says "row 2 differs" without showing both sequences sends the reader back to the debugger.
func srcsOfProposals(ps []BankExportProposal) []string {
out := make([]string, 0, len(ps))
for _, p := range ps {
out = append(out, p.Src)
}
return out
}
func srcsOfRows(rows []BankStopRow) []string {
out := make([]string, 0, len(rows))
for _, r := range rows {
out = append(out, r.Src)
}
return out
}
// 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)
}
if o.regenerate > 0 {
gates += fmt.Sprintf(" regenerate: %d\n", o.regenerate)
}
if o.classify {
cb := o.classifyBudgetUSD
if cb <= 0 {
cb = 1.0
}
gates += fmt.Sprintf(" classify_types: true\n classify_budget_usd: %g\n", cb)
}
}
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}}")
if o.classify {
// A DISTINCT user header so isClassifierBody can tell a classify call from a render call on the wire.
writeFile(t, filepath.Join(dir, "prompts", "zh-ru", "classifier.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 !strings.Contains(body, "langpack_root:") {
t.Fatal("premise broken: the fixture book no longer says `source_lang: ja`, so this helper silently produced a ja→ru book with no langpack and every pair-dependent assertion below would be vacuous")
}
if o.rejects != "" {
writeFile(t, filepath.Join(dir, "test-book.mined-rejects.yaml"), o.rejects)
}
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{}
allowEdit := false
draftCalls := 0
srv := newJSONProvider(rec, func(body string) (string, string) {
if isEditBody(body) {
if !allowEdit {
t.Errorf("the edit wave ran despite the bank-mining stop")
}
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop"
}
draftCalls++
return "Фан Юань пришёл к горе Цинмао." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{})
r1 := newVerifyRunner(t, bookPath)
runToSignatureStop(t, r1)
first := readSignatureMap(t, r1)
run1MapInode := inode(t, r1.signatureMapPath())
draftCallsAfterRun1 := draftCalls
r1.Close()
// Run 2 (new process): the owner decided NOTHING and resumed with the flag still raised — the flag
// model's central case. The run must NOT stop again (run 1 presented everything in this map), the
// drafts must replay from checkpoints rather than be re-bought, and the rewritten map must still
// carry the banknote WHAT byte-for-byte — the resume fast-path serves the cleaned final_hash, and a
// lost banknote_detail would silently empty the dst column.
allowEdit = true
r2 := newVerifyRunner(t, bookPath)
defer r2.Close()
res, err := r2.TranslateBook(context.Background())
var again *WaveSignatureStop
if errors.As(err, &again) {
t.Fatalf("run 2 re-stopped on the map run 1 already presented (%d terms) — the flag re-armed on undecided terms", again.Terms)
}
if err != nil {
t.Fatalf("the resumed run must go on past the stop: %v", err)
}
if res == nil || len(res.Chunks) == 0 {
t.Fatalf("the resumed run must complete the book, got %+v", res)
}
if draftCalls != draftCallsAfterRun1 {
t.Fatalf("the resumed draft wave must not re-call the provider: %d -> %d", draftCallsAfterRun1, draftCalls)
}
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)
}
// The map is REPLACED atomically (temp + rename), never truncated in place: a replace always changes
// the inode, and a reader on the other side of the seam must never observe a half-written map. The
// same discipline the two decision files already pin through their own inode test.
if inode(t, r2.signatureMapPath()) == run1MapInode {
t.Fatal("the map was rewritten in place (same inode) — a concurrent reader can observe a truncated document")
}
// The non-stopping half of §4.3: a raised flag must not switch the auto wire off — the unsigned rows
// rode forward.
if _, err := os.Stat(r2.autoBankPath()); err != nil {
t.Fatalf("the non-stopping run with the flag raised must still write the auto-bank: %v", err)
}
}
// 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, "Термины книги") }
// isClassifierBody detects the §2 classifier call by its distinct fixture user header.
func isClassifierBody(body string) bool {
return strings.Contains(body, "Классы терминов")
}
// TestClassifierCorrectsTypeBeforeRender is the §2 pin: a focused pass re-types a candidate BEFORE the
// render, and the corrected type reaches the banked row (attachClassifiedType) and the run's counters and
// ledger — on its OWN cost axis, separate from the render phase. Here the classifier re-types 青茅山 from the
// draft `place` to `term`; the signature map must record the corrected type.
func TestClassifierCorrectsTypeBeforeRender(t *testing.T) {
rec := &reqRec{}
var sawClassify, sawRenderAfterClassify bool
srv := newJSONProvider(rec, func(body string) (string, string) {
if isClassifierBody(body) {
sawClassify = true
return "方源\tname\n青茅山\tterm", "stop" // re-type the place to a realia term
}
if isTerminologyBody(body) {
sawRenderAfterClassify = sawClassify // the render must run AFTER the classify phase
return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop"
}
return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop"
})
defer srv.Close()
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, classify: true})
r := newVerifyRunner(t, bookPath)
defer r.Close()
runToSignatureStop(t, r)
yamlMap := readSignatureMap(t, r)
if !sawClassify {
t.Fatal("the classifier phase must have made a call")
}
if !sawRenderAfterClassify {
t.Fatal("the render phase must run AFTER the classifier phase (the corrected type primes the render)")
}
if r.lastTerminology.Reclassified != 1 {
t.Fatalf("exactly one type was re-derived, got Reclassified=%d", r.lastTerminology.Reclassified)
}
// The corrected type reaches the banked row, not the draft `place`.
place := termBlock(t, yamlMap, "青茅山")
if !strings.Contains(place, "type: term") {
t.Fatalf("the classifier's corrected type must be the banked type:\n%s", place)
}
// The classify spend is attributable on its OWN cost axis, apart from the render.
cspent, err := r.Store.RoleSpentUSD("test-book", roleClassifier)
if err != nil {
t.Fatal(err)
}
if cspent <= 0 || r.lastTerminology.ClassifyCostUSD <= 0 {
t.Fatalf("classify spend must be on its own ledger axis: ledger=%v result=%v", cspent, r.lastTerminology.ClassifyCostUSD)
}
}
// 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 carrying a rendering — while a term the role declines stays
// `auto`, inert. Nothing becomes `approved` on either branch. (The ⟨проверить⟩ marker that used to
// distinguish the draft mode ON THE WIRE left it with D39.104 п.2; the two modes are still two, and the
// difference is now the rendering itself.)
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 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.
//
// TWO branches, and until the fix-pack only the first was covered while the test's NAME claimed the second
// (§G3): prose is a non-empty completion the parser rejects line by line, whereas an EMPTY completion never
// reached the parser at all and took a silent `continue`. The fixture below is the honest one.
func TestTerminologistEmptyReplyIsLoud(t *testing.T) {
for _, tc := range []struct{ name, reply, want string }{
{"prose", "Извините, я не понял задание.", "returned nothing the parser could use"},
{"empty completion", "", "came back with an EMPTY completion"},
} {
t.Run(tc.name, func(t *testing.T) {
var logBuf bytes.Buffer
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
return tc.reply, "stop"
}
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(), tc.want) {
t.Fatalf("a paid batch that bought no terminology must say so (%s):\n%s", tc.name, 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 resumed 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 resume after a stop — and resuming without deciding is the flag
// model's normal case, so run 2 here decides nothing and must go on past the stop, not stop again.
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()
if _, err := r2.TranslateBook(context.Background()); err != nil {
t.Fatalf("the resumed run must go on past the stop (nothing in the map is new to it): %v", err)
}
if terminologyCalls != callsAfterRun1 {
t.Fatalf("a resume 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)
}
// ⛔ AND THE READ-OUT MUST NOT PRESENT THEM AS AWAITING SIGNATURE. This run MINED terms and did not
// stop, so they went into the bank as unsigned rows — which is the auto contract. A `proposed` section
// here would ask the signing screen for a decision the run already made, and it is the one boundary
// where the mistake is easy: the rows exist, the stop simply did not happen.
exp := readBankExport(t, r.bankExportPath())
if len(exp.Proposed) != 0 {
t.Fatalf("the read-out carries %d proposal(s) after an AUTO-CONTINUE — that section is a signature stop's, and this run did not stop", len(exp.Proposed))
}
if exp.Total == 0 {
t.Fatalf("test premise broken: the auto-continued bank is empty (total=%d), so «no proposals» proves nothing", exp.Total)
}
t.Logf("auto-continue read-out at %q: total=%d signed=%d proposed=%d", exp.AsOf, exp.Total, exp.Signed, len(exp.Proposed))
}
// 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)
}
}
}
// TestBankStopPublishesItsProposalsInTheReadOut closes backlog row 224. The stop refreshes the bank
// read-out PRECISELY so the signing screen has something to read at the boundary it opens — and the
// read-out projects the glossary, which the mined rows reach only in the auto-continue branch. So the one
// artifact that exists for this moment was guaranteed to be empty of the rows the moment exists for: a live
// run reported «total 0, signed 0» beside 82 unresolved surfaces.
//
// ⛔ THE PROPOSALS RIDE IN THEIR OWN SECTION, and the bank half is asserted UNCHANGED. Writing them into the
// bank instead would mean seeding them, and the seed path injects unsigned rows as law (D39.104 §2, with no
// branch on status) — the stop would become an auto-continue with a moved memory_version behind it, which is
// a re-snapshot of a paid book.
func TestBankStopPublishesItsProposalsInTheReadOut(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isTerminologyBody(body) {
// NOT the rendering the drafts proposed (that is «Фан Юань», in bankBlockForMining): the role
// reads the whole book and the drafts read fragments, so a consolidation of its own is the
// ordinary case — and it makes Invented TRUE on the published row rather than false-by-luck.
return "方源\tФан-Юань", "stop"
}
return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop"
})
defer srv.Close()
// TWO seeded rows, and neither is decoration. 蛊师 is one this corpus never yields, so the BANK half of
// the document is non-empty and "the stop left it alone" is a real comparison instead of "" == "".
// 方源 is an UNSIGNED row for a surface the run consolidates differently, which is what fills BankHolds
// on the published proposal — without it that list is empty, and so is Contradicts, and a projection
// that swapped the two would compare as "" against "" and pass. until_ch keeps the seed row off the
// proposal's unique key, so this is a disagreement rather than a replacement (the idiom
// TestConsolidationAgainstTheBankIsReportedAtTheStop established).
const seed = "terms:\n - src: 蛊师\n dst: гу-мастер\n status: approved\n decl: { invariant: true, forms: [\"гу-мастер\"] }\n" +
" - { src: 方源, dst: Странник, status: draft, until_ch: 20 }\n"
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, glossarySeed: seed}))
defer r.Close()
stop := runToSignatureStop(t, r)
exp := readBankExport(t, r.bankExportPath())
if exp.AsOf != "bank-mining/signature-stop" {
t.Fatalf("the read-out was last written at %q, want the stop boundary", exp.AsOf)
}
if len(exp.Proposed) == 0 {
t.Fatalf("the read-out published NO proposals while the stop presented %d — this is the row-224 defect itself", len(stop.Rows))
}
if len(exp.Proposed) != len(stop.Rows) {
t.Fatalf("the read-out carries %d proposals, the stop presented %d — the screen and the table would disagree", len(exp.Proposed), len(stop.Rows))
}
// ⛔ The section is the WHOLE fold of the table it came from — and this comparison catches exactly one
// class, deliberately: what the fold produced is what survived serialization and reached the file, in
// this order, at this boundary. It CANNOT see a defect in the fold itself, because a broken fold moves
// both sides of it equally; measured on 08.09, Freq×10 and a Kind↔Channel swap both survive this line.
// The fold's own correctness lives in TestEveryDecidingFieldSurvivesTheProjection, on a row where every
// field differs from every other, and the value anchors below cross-check it from the run's side.
if want := projectBankProposals(stop.Rows); !reflect.DeepEqual(exp.Proposed, want) {
t.Fatalf("the published section is not the fold of the table it came from:\n in the file %+v\n from the rows %+v", exp.Proposed, want)
}
// ORDER, anchored against the ROWS rather than against the fold — the one thing the comparison above
// structurally cannot see. The section's contract is that its order IS the stop's ranking, so which
// row an owner reads first is information and not layout; a fold that reversed it would pass every
// comparison that finds its row by Src instead of by position.
for i := range exp.Proposed {
if exp.Proposed[i].Src != stop.Rows[i].Src {
t.Fatalf("published row %d is %q, the stop ranked %q there — the sheet's order is the ranking, and this one is not it:\n file %v\n rows %v",
i, exp.Proposed[i].Src, stop.Rows[i].Src, srcsOfProposals(exp.Proposed), srcsOfRows(stop.Rows))
}
}
var fy *BankExportProposal
for i := range exp.Proposed {
if exp.Proposed[i].Src == "方源" {
fy = &exp.Proposed[i]
}
}
if fy == nil {
t.Fatalf("方源 is missing from the published proposals: %+v", exp.Proposed)
}
if fy.Dst != "Фан-Юань" || fy.Freq == 0 || len(fy.Variants) == 0 {
t.Fatalf("proposal = %+v, want the consolidated rendering, a frequency and the drafts' variants", *fy)
}
// THE FIXTURE IS NOT DEGENERATE, asserted rather than assumed. Every one of these was false before
// 08.09, and with all three false the comparison above degenerates: `false != false`, `0 != 0`, `0 != 0`
// pass whatever the fold did. Measured then: Freq×10, Invented forced false and the two lists swapped —
// all four planted at once — left the package green.
if !fy.Invented {
t.Fatalf("premise broken: the published rendering %q is one the drafts proposed, so `invented` is false and no assertion about it means anything: %+v", fy.Dst, *fy)
}
if len(fy.BankHolds) == 0 {
t.Fatalf("premise broken: the seeded row for %q did not reach the proposal as a bank hold, so bank_holds is empty and indistinguishable from contradicts: %+v", fy.Src, *fy)
}
if len(fy.Contradicts) != 0 {
t.Fatalf("premise broken the other way: this case is a disagreement with the BANK, not with the run itself; a non-empty contradicts means the fixture stopped being the one this test describes: %+v", *fy)
}
// VALUE ANCHORS against the stop row itself — the fold is not its own witness here. Every one is a
// field the fold RENAMES or could exchange with a neighbour of the same type, so a rescale or a swap
// has somewhere to show. Compared by value: the earlier form of this block compared the two string
// lists by LENGTH, and against a row whose lists were both empty that read as 0 != 0 and passed
// whatever the fold did.
var src *BankStopRow
for i := range stop.Rows {
if stop.Rows[i].Src == fy.Src {
src = &stop.Rows[i]
}
}
if src == nil {
t.Fatalf("no stop row for %q to anchor the projection against", fy.Src)
}
if fy.Kind != src.Type || fy.Channel != src.Origin {
t.Errorf("the sheet names kind=%q channel=%q; the row says type=%q origin=%q — these two are renamed by the fold and are exactly what an exchange would hide",
fy.Kind, fy.Channel, src.Type, src.Origin)
}
if fy.Freq != src.Freq || fy.Spread != src.Spread || fy.Conf != src.Conf {
t.Errorf("the sheet counts freq=%d spread=%d conf=%d; the row counts %d/%d/%d — a frequency that is not the row's is a number the owner signs against nothing",
fy.Freq, fy.Spread, fy.Conf, src.Freq, src.Spread, src.Conf)
}
if exp.Total != 2 || exp.Signed != 1 {
t.Fatalf("the bank half moved: total=%d signed=%d, want the two seeded rows with one signature — a proposal must not be banked by being published", exp.Total, exp.Signed)
}
// The surface itself IS in the bank — that is the seeded draft this case is built on. What must not be
// there is the proposal's own RENDERING: publishing a row for signature must not sign it. So the two
// are told apart by dst, not by src, and the seeded row is asserted to have stayed exactly as seeded.
var seeded bool
for _, term := range exp.Terms {
if term.Src != fy.Src {
continue
}
if term.Dst == fy.Dst {
t.Fatalf("the proposal was written INTO the bank: %+v", term)
}
if term.Dst == "Странник" {
seeded = true
}
}
if !seeded {
t.Fatalf("premise broken: the seeded draft for %q is gone from the bank, so «publishing did not touch the bank» is being asserted about a row that is not there: %+v", fy.Src, exp.Terms)
}
}
// 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 the owner's second verb end-to-end: a DECLINED term must leave
// the delta, and a fully-declined delta is EMPTY — the run auto-continues on the empty-delta path. The
// second project here is FRESH (its presented memory is empty), so the continue is decision-driven and
// cannot be the memory's doing; the explicit oracle is lastMinedCount == 0.
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 r.lastMinedCount != 0 {
t.Fatalf("every proposed term is declined, yet the delta still holds %d — the rejects did not reach the emission", r.lastMinedCount)
}
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. 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.
//
// ⚠ WHERE it arrives changed under this pack's brief (D39.104 п.2, backlog row 134): pack-20 put it in a
// separately headed section marked ⟨проверить⟩, and the doctrine folded that section into the one law
// block. The delivery guarantee — «the auto mode's rendering reaches the editor and the bank keeps it
// unsigned» — is what this test was for and is unchanged; the assertions below follow the row to where it
// now is instead of pinning a shape the doctrine retired.
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, "Фан Юань") {
t.Fatalf("the consolidated rendering must reach the editor:\n%s", editBody)
}
// It arrives inside the LAW block — the one the signed rows are in — and carries no mark of its own.
if !strings.Contains(editBody, tx.EditorHeader) || strings.Contains(editBody, tx.EditorUnverifiedHeader) {
t.Fatalf("the editor gets ONE law block and the unsigned rows are in it:\n%s", editBody)
}
if strings.Contains(editBody, tx.UnverifiedMarker) {
t.Fatalf("the ⟨проверить⟩ marker left the wire (D39.104 п.2):\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)
}
callsAfterFirst := rec.count()
r1.Close()
// ⛔ THE SETTLED BASIS COSTS ONE RE-PACK, ONCE, AND THE $0 CLAIM BELONGS TO THE RUN AFTER IT. Run 1
// writes the basis at its output boundary, so run 2 meets a composition run 1 never had and buys the
// bank pass once (bounded by gates.terminology.budget_usd and announced by the reconsolidation
// warning). From run 3 on the third input is stable and the whole run replays for $0 — which is what
// «a resumed auto-mode run must be $0» was always about, and what this test now asserts of a run that
// can actually deliver it.
rMid := newRunner(t, bookPath)
if _, err := rMid.TranslateBook(context.Background()); err != nil {
t.Fatalf("the basis re-pack run must complete cleanly, got %v", err)
}
if rMid.lastTerminology == nil || rMid.lastTerminology.BasisServed == 0 {
t.Fatalf("premise broken: the re-pack run must be served by the basis run 1 wrote, or the $0 claim below is about something else: %+v", rMid.lastTerminology)
}
// The premise, asserted rather than assumed: the re-pack run really did buy something. Without this the
// «$0 from here on» below could be true because the contour never ran at all, and the two are the same
// zero on the page.
calls1 := rec.count()
if calls1 <= callsAfterFirst {
t.Fatalf("premise broken: the basis re-pack bought nothing (provider calls %d → %d), so the $0 claim below is about a contour that never ran", callsAfterFirst, calls1)
}
rMid.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, "test-book.mined-rejects.yaml"), "rejects:\n - src: 方源\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")
gathered := []store.GlossaryEntry{{Src: "方源", Dst: "Фан Юань", Status: "approved", Source: "seed"}}
rows, dropped, err := r.loadAutoBank(gathered)
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)
}
// The holder here IS signed, and the line has to say so with the holder's own status rather than with
// the word "signed" — which the message used to print over any holder at all, signed or not
// (TestADropNamesItsHoldersSignature is where that half is pinned).
if !strings.Contains(joined, `key held by the approved "方源"`) {
t.Fatalf("the row colliding with a signed term must be dropped and say WHOSE key it hit: %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 row gathered earlier 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 holder wins and the run goes on. The holder here IS signed, and that is this
// fixture's case rather than the rule: the store's constraint does not read statuses, and an unsigned
// holder wins the tuple exactly as hard (TestADropNamesItsHoldersSignature).
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.
//
// The log line must LOCATE the fault as well as announce it: on a book with a thousand terms, a warning
// without the firing key and both windows does not say which two rows to reconcile.
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, "render ONE firing surface differently and at least one is unsigned") {
t.Fatalf("the approved-vs-unsigned contradiction must be reported (S14):\n%s", out)
}
for _, want := range []string{`approved \"方源\"→\"Фан Юань\"`, `unsigned draft \"方源\"→\"Источник Фана\"`, `firing key \"方源\"`, "[ch 1..end]", "[ch 2..end]"} {
if !strings.Contains(out, want) {
t.Fatalf("the report must name %s so the operator can reconcile the two rows:\n%s", want, 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, "test-book.mined-delta.yaml"),
"terms:\n - src: 花家\n dst: дом Хуа\n status: approved\n decl: { invariant: true, forms: [\"дом Хуа\"] }\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, func() ([]chunk.Chunk, error) { return chunks, nil }, newRepricerT(t, rp))
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)
}
}
}