package pipeline import ( "bytes" "context" "errors" "fmt" "log/slog" "os" "path/filepath" "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" // 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 `//.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 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) } } // TestBankStopWritesTheRichTable pins D39.36's «стоп с таблицей»: the stop carries the columns the owner // was promised — src · proposed dst · frequency · variant spread · evidence · contexts — and the FULL // table lands in a sidecar (the stdout view is capped; emitRankCap is 200). func TestBankStopWritesTheRichTable(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(body string) (string, string) { if isTerminologyBody(body) { return "方源\tФан Юань", "stop" } return "Фан Юань пришёл." + "\n" + bankSeparator + "\n方源\tФан Юань\tname\n方源\tФань Юань\tname", "stop" }) defer srv.Close() r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true})) defer r.Close() stop := runToSignatureStop(t, r) if len(stop.Rows) == 0 { t.Fatal("the stop must carry the table, not just a count") } if stop.TablePath != r.bankStopTablePath() { t.Fatalf("stop table path %q != %q", stop.TablePath, r.bankStopTablePath()) } var fy *BankStopRow for i := range stop.Rows { if stop.Rows[i].Src == "方源" { fy = &stop.Rows[i] } } if fy == nil { t.Fatalf("方源 missing from the table: %+v", stop.Rows) } if fy.Dst != "Фан Юань" || fy.Freq == 0 || fy.Origin != string(terminology.OriginBoth) { t.Fatalf("row = %+v, want the consolidated dst, a frequency and the both-channels origin", *fy) } if len(fy.Contexts) == 0 || len(fy.Evidence) == 0 { t.Fatalf("the row must carry its source contexts and its evidence: %+v", *fy) } raw, err := os.ReadFile(stop.TablePath) if err != nil { t.Fatalf("the sidecar must exist: %v", err) } for _, want := range []string{"方源", "Фан Юань", "origin=", "freq=", "spread=", "ctx:"} { if !strings.Contains(string(raw), want) { t.Fatalf("the sidecar must carry %q:\n%s", want, raw) } } } // TestBankStopNeverFiresInADraftOnlyPipeline pins S16. The stop's own contract is "before the edit wave"; // in a draft-only pipeline there is none, and stopping there discarded the assembled BookResult of a // draft wave that had already been paid for. func TestBankStopNeverFiresInADraftOnlyPipeline(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(string) (string, string) { return "Фан Юань пришёл." + "\n" + bankBlockForMining, "stop" }) defer srv.Close() bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{}) // Drop the edit stage: the draft IS the shipping output. pp := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml") raw, err := os.ReadFile(pp) if err != nil { t.Fatal(err) } var kept []string for _, ln := range strings.Split(string(raw), "\n") { if !strings.Contains(ln, "name: edit,") { kept = append(kept, ln) } } writeFile(t, pp, strings.Join(kept, "\n")) r := newVerifyRunner(t, bookPath) // asks to stop — and there is nothing to stop before defer r.Close() res, err := r.TranslateBook(context.Background()) if err != nil { t.Fatalf("a draft-only pipeline must not stop at the bank boundary, got %v", err) } if res == nil || len(res.Chunks) == 0 || res.Chunks[0].FinalText == "" { t.Fatalf("the draft-only BookResult must survive the boundary: %+v", res) } if _, serr := os.Stat(r.signatureMapPath()); serr != nil { t.Fatalf("the artifacts must still be written: %v", serr) } } // TestVerifyBankFailsLoudWhenMiningIsImpossible: asking to verify a bank a book cannot mine must be a // loud refusal, not a clean exit. A silent continue would return exit 0 for a verification that never // happened — the operator would read "no new terms" where the truth is "no detector". func TestVerifyBankFailsLoudWhenMiningIsImpossible(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() r := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})) // no langpack, no contrast defer r.Close() r.VerifyBank = true _, err := r.TranslateBook(context.Background()) if err == nil { t.Fatal("--verify-bank on a book that cannot mine must fail loud, not exit clean") } for _, want := range []string{"--verify-bank", "langpack_root", "mining.contrast_path"} { if !strings.Contains(err.Error(), want) { t.Fatalf("the refusal must name %q so the operator knows what is missing: %v", want, err) } } // Without the flag the same book runs normally — the refusal is scoped to the explicit request. r.VerifyBank = false if _, err := r.TranslateBook(context.Background()); err != nil { t.Fatalf("an unmined book must still translate without the flag: %v", err) } } // TestTerminologyIsNotSnapshotFolded pins the claim config.TerminologyGate makes. The role writes a FILE // the owner signs; it puts nothing into the bank, so it reaches the wire only through mined_delta → // memoryVersion → the edit-wave snapshot, which is folded already. Folding the gate itself would re-bill // a whole wave for enabling a step that changes no existing checkpoint — the mistake Mining.ContrastPath // documents avoiding. If this ever needs to change, it must change LOUDLY. func TestTerminologyIsNotSnapshotFolded(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() r := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{regenerate: 0})) defer r.Close() before := map[wave]string{} for _, w := range []wave{waveDraft, waveEdit} { id, _, err := r.snapshotIDForWave(w) if err != nil { t.Fatal(err) } before[w] = id } r.Pipeline.Gates.Terminology = config.TerminologyGate{ Enabled: true, Model: "fake-model", BudgetUSD: 1, BatchRunes: 999, KWICPerTerm: 7, KWICWidth: 11, } for _, w := range []wave{waveDraft, waveEdit} { id, _, err := r.snapshotIDForWave(w) if err != nil { t.Fatal(err) } if id != before[w] { t.Fatalf("enabling terminology moved the %v snapshot (%s → %s) — that re-bills a paid wave for a step that changes no checkpoint", w, before[w][:12], id[:12]) } } } // termBlock returns the YAML block of one term from a signature map (from its `- src:` line to the next). func termBlock(t *testing.T, yamlMap, src string) string { t.Helper() lines := strings.Split(yamlMap, "\n") start := -1 for i, ln := range lines { if strings.HasPrefix(strings.TrimSpace(ln), "- src: ") && strings.Contains(ln, src) { start = i continue } if start >= 0 && strings.HasPrefix(strings.TrimSpace(ln), "- src: ") { return strings.Join(lines[start:i], "\n") } } if start < 0 { t.Fatalf("term %q not found in the signature map:\n%s", src, yamlMap) } return strings.Join(lines[start:], "\n") } // trustGateSeed puts an APPROVED short key inside a lower-trust LONGER one: reading 四代族長 at ch1, the // draft key wants to eat the nested approved 族長 by longest-match. The disposition gate refuses (D39 // layer 4) — the draft is editor-excluded, so eating the approved term would leave the reader with // nothing AND the drop would be invisible. const trustGateSeed = ` terms: - src: 族長 dst: глава клана status: approved decl: { invariant: true, forms: ["глава клана"] } - src: 四代族長 dst: четвёртый глава status: draft decl: { invariant: true, forms: ["четвёртый глава"] } ` // TestTrustGateDetailPersists pins chunkrun.go:131-139 — the third un-pinned call-site of the same class // (§6 of the phase-1 design). The REFUSED suppression is the term-drift code root made loud: if the // detail stops being written, the refusal degrades back to the near-silent drop research/13 §7 describes, // and no other test notices because the count alone says nothing about WHICH pair collided. func TestTrustGateDetailPersists(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{ source: "四代族長が来た。族長は強い。", glossarySeed: trustGateSeed, regenerate: 0, }) r := newRunner(t, bookPath) defer r.Close() if _, err := r.TranslateBook(context.Background()); err != nil { t.Fatal(err) } rs, err := r.Store.GetRetrievalState("test-book", 1, 0) if err != nil || rs == nil { t.Fatalf("no retrieval_state: %v", err) } if rs.NTrustGatedSuppress != 1 { t.Fatalf("want exactly one refused suppression, got %d", rs.NTrustGatedSuppress) } // The count is not enough: the human needs the PAIR to reconcile the seed. The detail carries the // bank's NORMALIZED keys (the seed is written in traditional forms; the bank folds trad→simp), so a // reader looking for their own spelling is looking at the matcher's spelling — worth pinning as such. if !strings.Contains(rs.TrustGateDetail, "四代族长") || !strings.Contains(rs.TrustGateDetail, "族长") { t.Fatalf("the detail must name the suppressor→protected pair, got %q", rs.TrustGateDetail) } if !strings.Contains(rs.TrustGateDetail, string(membank.Ambiguous)) || !strings.Contains(rs.TrustGateDetail, string(membank.Confirmed)) { t.Fatalf("the detail must carry both dispositions (that is WHY it was refused), got %q", rs.TrustGateDetail) } } // TestMiningStopRejectClearsDelta pins 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") signed := []store.GlossaryEntry{{Src: "方源", Dst: "Фан Юань", Status: "approved", Source: "seed"}} rows, dropped, err := r.loadAutoBank(signed) if err != nil { t.Fatal(err) } if len(rows) != 1 || rows[0].Src != "青茅山" { t.Fatalf("only the clean row may enter the bank, got %+v", rows) } if rows[0].Source != "mined" { t.Fatalf("an engine row must be base-excluded (Source:mined), got %q", rows[0].Source) } joined := strings.Join(dropped, " | ") if !strings.Contains(joined, "花家") || !strings.Contains(joined, "declined") { t.Fatalf("the DECLINED row must be dropped and said out loud: %q", joined) } if !strings.Contains(joined, "方源") || !strings.Contains(joined, "signed") { t.Fatalf("the row colliding with a SIGNED term must be dropped and said out loud: %q", joined) } } // TestAutoBankKeyCollisionDoesNotCrashTheRun is risk 4 end-to-end: an auto row whose UNIQUE key // (src, sense, since_ch, until_ch) is already held by a signed term would make the flat INSERT in // ReplaceGlossary hit the constraint and abort a PAID run mid-flight. The engine's own proposal must // never be able to do that — the signed term wins and the run goes on. func TestAutoBankKeyCollisionDoesNotCrashTheRun(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() const seed = "terms:\n - src: 方源\n dst: Фан Юань\n status: approved\n decl: { invariant: true, forms: [\"Фан Юань\"] }\n" bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{glossarySeed: seed}) r := newRunner(t, bookPath) // The engine proposes a DIFFERENT rendering for a term the owner already signed, on the same key. writeFile(t, r.autoBankPath(), "terms:\n - src: 方源\n dst: Источник Фана\n status: draft\n") res, err := r.TranslateBook(context.Background()) if err != nil { t.Fatalf("a colliding engine proposal must not abort a paid run: %v", err) } if res == nil || len(res.Chunks) == 0 { t.Fatalf("the run must complete: %+v", res) } rows, err := r.Store.GlossaryForBook("test-book") if err != nil { t.Fatal(err) } for _, e := range rows { if e.Src == "方源" && e.Dst != "Фан Юань" { t.Fatalf("the SIGNED rendering must win over the engine's proposal, got %+v", e) } } r.Close() } // TestUnsignedConflictIsReportedByTheRun wires S14's diagnostic to the run itself: a bank that holds an // approved term AND an unsigned proposal contradicting it on the same firing surface must say so out // loud. The check is a WARNING by design — the unsigned row is engine-produced, and aborting a paid run // over the engine's own proposal would be a self-inflicted outage — so the assertion is on the log. func TestUnsignedConflictIsReportedByTheRun(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, draftEdit) defer srv.Close() const seed = "terms:\n - src: 方源\n dst: Фан Юань\n status: approved\n decl: { invariant: true, forms: [\"Фан Юань\"] }\n" bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{glossarySeed: seed}) r := newRunner(t, bookPath) defer r.Close() // A DIFFERENT unsigned rendering of a surface the owner already signed, on its own key (a different // spoiler window, so ReplaceGlossary accepts both rows and BOTH are injected). writeFile(t, r.autoBankPath(), "terms:\n - src: 方源\n dst: Источник Фана\n status: draft\n since_ch: 2\n") var logs bytes.Buffer r.Log = slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelWarn})) if _, err := r.TranslateBook(context.Background()); err != nil { t.Fatal(err) } out := logs.String() if !strings.Contains(out, "contradicts an approved term") { t.Fatalf("the approved-vs-unsigned contradiction must be reported (S14):\n%s", out) } if !strings.Contains(out, "Источник Фана") { t.Fatalf("the report must name the contradicting rendering:\n%s", out) } } // TestUnverifiedChannelOnADraftOnlyPipeline covers the OTHER writer of the same counters. In a two-stage // book the shipping wire is the editor, so the edit unit records them; in a draft-only book the draft IS // the shipping output and the translator block is where the unsigned row is shown. Both paths must // record, or the channel is silently half-blind depending on the pipeline shape. func TestUnverifiedChannelOnADraftOnlyPipeline(t *testing.T) { rec := &reqRec{} srv := newJSONProvider(rec, func(string) (string, string) { return "Фан Юань пришёл к горе.", "stop" // follows the unsigned rendering }) defer srv.Close() const seed = "terms:\n - src: 方源\n dst: Фан Юань\n status: draft\n decl: { invariant: true, forms: [\"Фан Юань\"] }\n" bookPath := setupProjectOpts(t, srv.URL, projectOpts{ source: miningStopSource, glossarySeed: seed, regenerate: 0, }) // Draft-only: drop the edit stage so the draft is the shipping text. pp := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml") raw, err := os.ReadFile(pp) if err != nil { t.Fatal(err) } var kept []string for _, ln := range strings.Split(string(raw), "\n") { if !strings.Contains(ln, "name: edit,") { kept = append(kept, ln) } } writeFile(t, pp, strings.Join(kept, "\n")) r := newRunner(t, bookPath) defer r.Close() if _, err := r.TranslateBook(context.Background()); err != nil { t.Fatal(err) } rs, err := r.Store.GetRetrievalState("test-book", 1, 0) if err != nil || rs == nil { t.Fatalf("no retrieval_state: %v", err) } if rs.NUnverifiedShown == 0 || rs.NUnverifiedFollowed == 0 { t.Fatalf("the draft wire showed the unsigned row and the model used it — both must be counted: %+v", rs) } if rs.NPostcheckMiss != 0 { t.Fatalf("an unsigned row must never enter the CONFIRMED-miss count: %+v", rs) } } // --- pointwise re-edit by key (pack-20 point 5 / D39.42 п.5) ------------------------------------------ // TestPointwiseReEditOnlyPaysForTheUnitsTheTermTouches is the money proof of the pack's last point. The // owner signs ONE term after the edit wave is paid for. Before pack-20 that moved the edit-wave snapshot // and re-bought EVERY unit of the wave — the whole book for one word. Now the units whose injected bytes // did not change are re-pinned for $0, and only the chapter where the term actually occurs is re-edited. func TestPointwiseReEditOnlyPaysForTheUnitsTheTermTouches(t *testing.T) { rec := &reqRec{} editCalls := 0 srv := newJSONProvider(rec, func(body string) (string, string) { if isEditBody(body) { editCalls++ return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" } return "ЧЕРНОВИК ПЕРЕВОДА", "stop" }) defer srv.Close() bookPath := setupProjectOpts(t, srv.URL, projectOpts{ regenerate: 0, epub: []chunktest.Chapter{ {ID: "c1", Href: "ch1.xhtml", Body: `

方源来到青茅山。方源很强。

`}, {ID: "c2", Href: "ch2.xhtml", Body: `

花家很大。花家的人很多。

`}, }, 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) } } }