1887 lines
87 KiB
Go
1887 lines
87 KiB
Go
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
|
||
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.classify {
|
||
gates += " classify_types: true\n classify_budget_usd: 1.0\n"
|
||
}
|
||
}
|
||
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.069–0.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)
|
||
}
|
||
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, "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)
|
||
}
|
||
}
|
||
}
|