From 7d0c6f2f5418c43076d082daef9e21310a81636b Mon Sep 17 00:00:00 2001 From: heaven Date: Fri, 28 Aug 2026 11:41:43 +0300 Subject: [PATCH] Land the backend pack so the memory bank reaches every provider, a hole in the shipped text is visible to its reader, and a repeated decision document converges --- backend/cmd/tmctl/exitcontract_test.go | 16 + backend/cmd/tmctl/exportgap_cli_test.go | 382 ++++++++++++++++++ backend/cmd/tmctl/render.go | 94 ++++- backend/cmd/tmmutate/mutations.json | 170 +++++++- backend/configs/models.yaml | 15 + backend/internal/config/models.go | 31 ++ .../internal/config/models_catalog_test.go | 114 ++++++ backend/internal/llm/capability.go | 59 +++ backend/internal/llm/httpllm.go | 49 ++- backend/internal/llm/provider_local.go | 8 +- backend/internal/llm/provider_openai.go | 9 +- backend/internal/llm/systemmessages_test.go | 233 +++++++++++ backend/internal/membank/decisions.go | 53 ++- .../membank/decisions_converge_test.go | 218 ++++++++++ .../internal/membank/decisions_fuzz_test.go | 11 + backend/internal/pipeline/bankdecisions.go | 48 ++- .../pipeline/bankdecisions_converge_test.go | 185 +++++++++ backend/internal/pipeline/bankreport_test.go | 52 ++- backend/internal/pipeline/bookrun.go | 13 + backend/internal/pipeline/export.go | 29 ++ backend/internal/pipeline/mining.go | 34 ++ backend/internal/pipeline/runner_test.go | 12 +- .../pipeline/systemmessages_wave_test.go | 138 +++++++ backend/internal/pipeline/waverun.go | 4 +- docs/PROGRESS.md | 40 +- docs/README.md | 2 +- docs/architecture/05-decisions-index.md | 3 +- docs/architecture/05-decisions-log.md | 85 +++- ..._SILENT_HARM_SESSION_PROMPT_2026-08-28.md} | 0 29 files changed, 2049 insertions(+), 58 deletions(-) create mode 100644 backend/cmd/tmctl/exportgap_cli_test.go create mode 100644 backend/internal/llm/systemmessages_test.go create mode 100644 backend/internal/membank/decisions_converge_test.go create mode 100644 backend/internal/pipeline/bankdecisions_converge_test.go create mode 100644 backend/internal/pipeline/systemmessages_wave_test.go rename docs/{BACKEND_SILENT_HARM_SESSION_PROMPT.md => archive/prompts/BACKEND_SILENT_HARM_SESSION_PROMPT_2026-08-28.md} (100%) diff --git a/backend/cmd/tmctl/exitcontract_test.go b/backend/cmd/tmctl/exitcontract_test.go index fe54ef9a..1751cef2 100644 --- a/backend/cmd/tmctl/exitcontract_test.go +++ b/backend/cmd/tmctl/exitcontract_test.go @@ -33,9 +33,24 @@ import ( var ( tmctlOnce sync.Once tmctlPath string + tmctlDir string tmctlErr error ) +// TestMain removes the directory buildTmctl compiles into. Without it every `go test ./cmd/tmctl/` +// left a 20 MB binary in the system temp dir forever: t.TempDir cannot serve here (the binary is +// shared by the whole package, outliving any one test), and the sync.Once has no teardown of its own. +// Measured, not theorised — 249 abandoned `tmctl-bin*` directories, ~5 GB, filled the machine's /tmp +// during this pack and turned the -race battery into "no space left on device", which reads exactly +// like a broken build. +func TestMain(m *testing.M) { + code := m.Run() + if tmctlDir != "" { + _ = os.RemoveAll(tmctlDir) + } + os.Exit(code) +} + // buildTmctl compiles the CLI once for the whole package. func buildTmctl(t *testing.T) string { t.Helper() @@ -45,6 +60,7 @@ func buildTmctl(t *testing.T) string { tmctlErr = err return } + tmctlDir = dir // TestMain removes it; see the note there tmctlPath = filepath.Join(dir, "tmctl") out, err := exec.Command("go", "build", "-o", tmctlPath, ".").CombinedOutput() if err != nil { diff --git a/backend/cmd/tmctl/exportgap_cli_test.go b/backend/cmd/tmctl/exportgap_cli_test.go new file mode 100644 index 00000000..a8f204e1 --- /dev/null +++ b/backend/cmd/tmctl/exportgap_cli_test.go @@ -0,0 +1,382 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "textmachine/backend/internal/pipeline" +) + +// exportgap_cli_test.go is the §3.2 proof, and it is deliberately end-to-end through the REAL +// binary: the claim under test is about the bytes a human reads out of `tmctl export`, and only +// the binary produces those bytes. A unit-level assertion on a struct would prove the projection +// and leave the shipped text unexamined — which is exactly where the defect lived. +// +// THE DEFECT: a c-lite member drop ships the editor's text over the unit's CLEAN members and +// leaves the flagged member's text out entirely. The reader got a seamless concatenation with a +// chunk-sized hole in it, under a banner that said «leak cleaned, verify» — the wording for a +// COSMETIC sanitizer strip, which had not happened. So the marker was not missing; it was wrong, +// and a wrong marker is worse than none, because it answers the reader's question falsely. + +// gapProvider drives one unit into the c-lite state: the member paragraph carrying echoMarker +// comes back as untranslated CJK (classify → cjk_artifact → the member's draft flags and is +// dropped from the edit), the other translates, and the editor returns its own text. +func gapProvider(t *testing.T, echoMarker string) *httptest.Server { + t.Helper() + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, _ := io.ReadAll(r.Body) + text := "ЧЕРНОВИК ПЕРЕВОДА" + switch { + case strings.Contains(string(body), "Черновик перевода для редактуры"): + text = "Отредактированный текст уцелевшей части." + case strings.Contains(string(body), echoMarker): + text = "这是完全没有翻译的中文内容。" + } + tb, _ := json.Marshal(text) + fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":"stop"}], + "usage":{"prompt_tokens":100,"completion_tokens":50}}`, tb) + })) + t.Cleanup(srv.Close) + return srv +} + +// setupGapProject is setupCLIProject plus an EDIT stage (units need members to lose one) and a +// two-paragraph source, the second of which the provider echoes. +func setupGapProject(t *testing.T, providerURL, echoMarker string) string { + t.Helper() + dir := t.TempDir() + writeCLIFile(t, filepath.Join(dir, "prompts", "translator.md"), + "Переводи с {{source_lang}} на {{target_lang}}.\n---USER---\n{{text}}") + writeCLIFile(t, filepath.Join(dir, "prompts", "editor.md"), + "Редактируй перевод.\n---USER---\nИсходник: {{text}}\nЧерновик перевода для редактуры: {{draft}}") + writeCLIFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(` +prices_checked: %q +default_model: fake-model +providers: + fake: + kind: openai + base_url: %q + timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 } +models: + fake-model: + provider: fake + price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 } +`, time.Now().UTC().Format("2006-01-02"), providerURL)) + writeCLIFile(t, filepath.Join(dir, "pipeline.yaml"), ` +core: C1 +version: 1 +defaults: { max_output_ratio: 2.0, min_max_tokens: 512 } +retries: { regenerate_before_escalate: 0 } +context: { glossary_injection: selective, glossary_token_budget: 800 } +stages: + - { name: draft, role: translator, model: fake-model, prompt_override: prompts/translator.md, prompt_version: v-cli, temperature: 0.3, reasoning: "off" } + - { name: edit, role: editor, model: fake-model, prompt_override: prompts/editor.md, prompt_version: v-cli, temperature: 0.4, reasoning: "off" } +`) + writeCLIFile(t, filepath.Join(dir, "source.txt"), + strings.Repeat("文", 1400)+"。\n\n"+strings.Repeat(echoMarker, 1100)+"。") + writeCLIFile(t, filepath.Join(dir, "book.yaml"), ` +book_id: gap-book +title: Тест +source_lang: zh +target_lang: ru +genre: ранобэ +audience: тест +venuti: 0.5 +honorifics: keep +transcription: palladius +footnotes: minimal +pipeline: pipeline.yaml +models: models.yaml +source_file: source.txt +ceilings: { book_usd: 1.0, day_usd: 2.0 } +`) + return filepath.Join(dir, "book.yaml") +} + +// setupStrippedDraftOnlyProject builds a DRAFT-ONLY book with the output sanitizer on, whose single +// chunk comes back with a leading markdown header. The strip removes the «### » artifact and ships the +// whole prose: the chunk is flagged sanitizer_stripped and loses NO reader-visible text. +func setupStrippedDraftOnlyProject(t *testing.T, providerURL string) string { + t.Helper() + cfg := setupCLIProject(t, providerURL) + writeCLIFile(t, filepath.Join(filepath.Dir(cfg), "pipeline.yaml"), ` +core: C1 +version: 1 +defaults: { max_output_ratio: 2.0, min_max_tokens: 512 } +retries: { regenerate_before_escalate: 0 } +context: { glossary_injection: selective, glossary_token_budget: 800 } +stages: + - { name: draft, role: translator, model: fake-model, prompt_override: prompts/translator.md, prompt_version: v-cli, temperature: 0.3, reasoning: "off" } +gates: + sanitizer: + enabled: true +`) + return cfg +} + +// TestADraftOnlyStrippedChunkIsNotCalledIncomplete is the ANTI-SCOPE guard, and it is here because an +// adversarial pass found the change failing it: a draft-only pipeline makes every chunk a SINGLETON +// unit whose own draft row is its final row, so the member-drop rule counted the chunk's own flag as a +// lost member. The reader was then told a fragment was missing from a chunk whose text is entirely +// present — a marker that misinforms, which the order forbids as explicitly as the silence it replaces. +func TestADraftOnlyStrippedChunkIsNotCalledIncomplete(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + tb, _ := json.Marshal("### Глава 7\n\nСудзуки открыл седьмую дверь и замер на пороге.") + fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":"stop"}], + "usage":{"prompt_tokens":100,"completion_tokens":50}}`, tb) + })) + t.Cleanup(srv.Close) + bookPath := setupStrippedDraftOnlyProject(t, srv.URL) + + report := runTmctl(t, bookPath, "translate") + shipped := runTmctl(t, bookPath, "export", "--plaintext") + t.Logf("TRANSLATE:\n%s\nEXPORT:\n%s", report, shipped) + + var doc pipeline.BookExport + if err := json.Unmarshal([]byte(runTmctl(t, bookPath, "export")), &doc); err != nil { + t.Fatal(err) + } + if len(doc.Chunks) != 1 || doc.Chunks[0].FlagReason != string(pipeline.FlagSanitizerStripped) { + t.Fatalf("fixture must produce ONE cosmetically stripped chunk, got %+v", doc.Chunks) + } + if doc.Chunks[0].DroppedMembers != 0 { + t.Fatalf("a draft-only unit has no members to drop, got dropped_members=%d", doc.Chunks[0].DroppedMembers) + } + // The whole prose is there — nothing was lost, so nothing may claim it was. + if !strings.Contains(shipped, "Судзуки открыл седьмую дверь и замер на пороге.") { + t.Fatalf("the stripped chunk must still ship its prose:\n%s", shipped) + } + for _, out := range []string{shipped, report} { + if strings.Contains(out, "TEXT MISSING") || strings.Contains(out, "INCOMPLETE") { + t.Fatalf("nothing was lost; the gap marker must not fire:\n%s", out) + } + } + // And the TRUE banner is restored, not shadowed by the incomplete branch. + if !strings.Contains(shipped, "(leak cleaned, verify)") { + t.Fatalf("a real cosmetic strip must keep its own banner:\n%s", shipped) + } + if !strings.Contains(shipped, "(of them incomplete=0)") { + t.Fatalf("the summary must not count it as incomplete:\n%s", shipped) + } +} + +// runTmctl runs the real binary and returns its stdout, tolerating the documented non-error exit +// codes (2 = completed with flags — which is precisely the run this test needs). +func runTmctl(t *testing.T, bookPath string, args ...string) string { + t.Helper() + cmd := exec.Command(buildTmctl(t), append(args, "--config", bookPath)...) + var out, errb bytes.Buffer + cmd.Stdout, cmd.Stderr = &out, &errb + err := cmd.Run() + if code := exitCodeOf(t, err); code != 0 && code != 2 { + t.Fatalf("tmctl %v exited %d\nstdout:\n%s\nstderr:\n%s", args, code, out.String(), errb.String()) + } + return out.String() +} + +// TestExportPlaintextMarksTheGapInTheShippedText is the deliverable: on a real c-lite run, the +// text a human reads out of `tmctl export --plaintext` says a piece is missing, and does NOT +// claim a cosmetic clean-up that never happened. +func TestExportPlaintextMarksTheGapInTheShippedText(t *testing.T) { + const echoMarker = "禁" + srv := gapProvider(t, echoMarker) + bookPath := setupGapProject(t, srv.URL, echoMarker) + + runTmctl(t, bookPath, "translate") + shipped := runTmctl(t, bookPath, "export", "--plaintext") + t.Logf("SHIPPED TEXT:\n%s", shipped) + + // The state really is the one under test: an ok edit over the clean member, with a member lost. + var doc pipeline.BookExport + if err := json.Unmarshal([]byte(runTmctl(t, bookPath, "export")), &doc); err != nil { + t.Fatalf("export --json must round-trip: %v", err) + } + if len(doc.Chunks) != 1 { + t.Fatalf("fixture must produce ONE unit, got %d — the c-lite state was not reached", len(doc.Chunks)) + } + u := doc.Chunks[0] + if u.Disposition != string(pipeline.DispFlagged) || u.FinalText == "" || u.DroppedMembers != 1 { + t.Fatalf("fixture must produce a flagged unit that SHIPS text with 1 dropped member, got %+v", u) + } + + // 1. The reader is told, in the text stream, that a piece is missing. + if !strings.Contains(shipped, "TEXT MISSING") { + t.Fatalf("the shipped text does not tell the reader a fragment is missing:\n%s", shipped) + } + if !strings.Contains(shipped, "1 source fragment of this unit could not be translated") { + t.Fatalf("the marker must say HOW MUCH is missing:\n%s", shipped) + } + // 2. And it is NOT told the false thing it used to be told. + if strings.Contains(shipped, "leak cleaned") { + t.Fatalf("a member drop must not be reported as a cosmetic sanitizer clean-up:\n%s", shipped) + } + // 3. The marker sits with the prose, above the text it qualifies — not only in a header far + // above, which a reader scrolling a concatenation passes once and never sees again. + iMark, iText := strings.Index(shipped, "TEXT MISSING"), strings.Index(shipped, "Отредактированный текст") + if iMark < 0 || iText < 0 || iMark > iText { + t.Fatalf("the marker must precede the incomplete text (marker@%d, text@%d):\n%s", iMark, iText, shipped) + } + // 4. The surviving text still ships in full — the marker informs, it does not withhold. + if !strings.Contains(shipped, "Отредактированный текст уцелевшей части.") { + t.Fatalf("the paid, clean remainder must still ship:\n%s", shipped) + } + // 5. The summary counts it as incomplete rather than folding it into `exported`. + if !strings.Contains(shipped, "(of them incomplete=1)") { + t.Fatalf("the export summary must count the incomplete unit:\n%s", shipped) + } +} + +// TestTranslateMarksTheGapInTheShippedText: the same run's own report is the OTHER surface a human +// reads, and it carried the same false wording. One state, both renderers. +func TestTranslateMarksTheGapInTheShippedText(t *testing.T) { + const echoMarker = "禁" + srv := gapProvider(t, echoMarker) + bookPath := setupGapProject(t, srv.URL, echoMarker) + + report := runTmctl(t, bookPath, "translate") + t.Logf("TRANSLATE REPORT:\n%s", report) + + if !strings.Contains(report, "TEXT MISSING") { + t.Fatalf("translate must tell the reader a fragment is missing:\n%s", report) + } + if strings.Contains(report, "leak cleaned") { + t.Fatalf("translate must not report a member drop as a cosmetic clean-up:\n%s", report) + } + if !strings.Contains(report, "Отредактированный текст уцелевшей части.") { + t.Fatalf("the clean remainder must still be printed:\n%s", report) + } +} + +// TestExportPlaintextStateMatrix pins all four unit states on ONE document, so the branches cannot +// drift apart: only the member-drop unit is called incomplete, only a real sanitizer strip is +// called a cleaned leak, a withheld unit is neither, and the header counts each honestly. +func TestExportPlaintextStateMatrix(t *testing.T) { + doc := &pipeline.BookExport{BookID: "b", TotalUnits: 8, PendingUnits: 1, Chunks: []pipeline.ChunkExport{ + {Chapter: 1, ChunkIdx: 0, Disposition: "ok", FinalText: "Чистый текст."}, + {Chapter: 2, ChunkIdx: 0, Disposition: "flagged", FlagReason: "sanitizer_stripped", FinalText: "Очищенный текст."}, + {Chapter: 3, ChunkIdx: 0, Disposition: "flagged", FlagReason: "cjk_artifact", FinalText: "Уцелевшая часть.", DroppedMembers: 2, DroppedReason: "cjk_artifact"}, + {Chapter: 4, ChunkIdx: 0, Disposition: "flagged", FlagReason: "glossary_miss", FinalText: ""}, + {Chapter: 5, ChunkIdx: 0, Disposition: "pending"}, + // EVERY member dropped: the unit ships NOTHING. It carries a drop count all the same, and the + // marker must NOT fire — «a fragment is missing from the text below» over an empty body points + // at text that does not exist. This case is why both call sites guard on a non-empty text. + {Chapter: 6, ChunkIdx: 0, Disposition: "flagged", FlagReason: "cjk_artifact", FinalText: "", DroppedMembers: 3, DroppedReason: "cjk_artifact"}, + // A flagged unit with the sanitizer's reason but NO text. The engine does not produce one today + // (classifyOutput hands back a non-empty strip or a different reason), but the branch order must + // not be the thing standing between that and a banner announcing a clean-up of nothing. + {Chapter: 7, ChunkIdx: 0, Disposition: "flagged", FlagReason: "sanitizer_stripped", FinalText: ""}, + // THE INTERSECTION, and the one an adversarial pass caught: the EDIT flagged on its own account + // (a cosmetic strip) AND a member dropped. The unit's FlagReason is the strip's; the cause of + // the HOLE is the member's. Printing the unit's reason as the cause of the loss would tell the + // reader a clean-up ate a chunk of the book — the same false claim, moved into the prose. + {Chapter: 8, ChunkIdx: 0, Disposition: "flagged", FlagReason: "sanitizer_stripped", + FinalText: "Почищенная уцелевшая часть.", DroppedMembers: 1, DroppedReason: "untranslated_echo"}, + }} + var b bytes.Buffer + if err := renderExport(&b, doc, true); err != nil { + t.Fatal(err) + } + out := b.String() + + for _, want := range []string{ + // exported counts only units that actually shipped text: 8 total − 1 pending − 3 withheld = 4, + // of which 2 are incomplete (a SUBSET of exported, not a fourth part of the total). + "# export b — total units=8, exported=4 (of them incomplete=2), withheld=3, pending=1", + "=== CHAPTER 1 CHUNK 0 ===\nЧистый текст.", + // A REAL cosmetic strip keeps the wording that is true of it, and gains no gap marker. + "=== CHAPTER 2 CHUNK 0 — flagged (sanitizer_stripped) (leak cleaned, verify) ===\nОчищенный текст.", + // The member drop is named, counted and marked in the text stream — plural form included. + "=== CHAPTER 3 CHUNK 0 — flagged (cjk_artifact) (INCOMPLETE) ===\n" + + "[⚠ TEXT MISSING — 2 source fragments of this unit could not be translated and are NOT in the text below: cjk_artifact]\n" + + "Уцелевшая часть.", + "=== CHAPTER 4 CHUNK 0 — flagged (glossary_miss) (not translated, flagged for a human) ===", + "=== CHAPTER 6 CHUNK 0 — flagged (cjk_artifact) (not translated, flagged for a human) ===", + "=== CHAPTER 7 CHUNK 0 — flagged (sanitizer_stripped) (not translated, flagged for a human) ===", + // The banner keeps the unit's verdict; the MARKER names the hole's own cause, not the strip's. + "=== CHAPTER 8 CHUNK 0 — flagged (sanitizer_stripped) (INCOMPLETE) ===\n" + + "[⚠ TEXT MISSING — 1 source fragment of this unit could not be translated and is NOT in the text below: untranslated_echo]\n" + + "Почищенная уцелевшая часть.", + "=== CHAPTER 5 CHUNK 0 — pending (not yet translated) ===", + } { + if !strings.Contains(out, want) { + t.Fatalf("plaintext export must contain\n%q\ngot:\n%s", want, out) + } + } + // ANTI-SCOPE: a unit that lost nothing must never carry the marker — a marker on complete text + // misinforms the reader exactly as the old wording did, in the other direction. + head := out[:strings.Index(out, "=== CHAPTER 3")] + if strings.Contains(head, "TEXT MISSING") { + t.Fatalf("the gap marker leaked onto a unit that lost nothing:\n%s", head) + } + if n := strings.Count(out, "TEXT MISSING"); n != 2 { + t.Fatalf("exactly two units ship INCOMPLETE text, got %d markers:\n%s", n, out) + } + // The strip's reason must never be printed as the CAUSE of a hole. + if strings.Contains(out, "could not be translated and is NOT in the text below: sanitizer_stripped") { + t.Fatalf("the marker named the unit's own flag reason as the cause of the loss:\n%s", out) + } + // The wholly-withheld unit (ch.6: every member dropped, nothing shipped) must not have been dressed + // as merely incomplete — scoped to ITS block, since ch.8 legitimately carries both. + ch6 := out[strings.Index(out, "=== CHAPTER 6"):strings.Index(out, "=== CHAPTER 7")] + if strings.Contains(ch6, "TEXT MISSING") || strings.Contains(ch6, "INCOMPLETE") { + t.Fatalf("a unit that ships NOTHING must not claim a fragment is missing below it:\n%s", ch6) + } +} + +// TestTranslateStateMatrix is renderExport's matrix for the OTHER human surface. Its twin existed and +// this one did not, so an adversarial pass could revert renderTranslate's two guards — the ones the +// code itself calls LOAD-BEARING — and watch the whole cmd/tmctl suite stay green. A guard nothing +// pins is a comment. +func TestTranslateStateMatrix(t *testing.T) { + res := &pipeline.BookResult{BookID: "b", Flagged: 5, Chunks: []pipeline.ChunkOutcome{ + {Chapter: 1, Disposition: pipeline.DispOK, FinalText: "Чистый текст."}, + {Chapter: 2, Disposition: pipeline.DispFlagged, FlagReason: pipeline.FlagSanitizerStripped, FinalText: "Очищенный текст."}, + {Chapter: 3, Disposition: pipeline.DispFlagged, FlagReason: "cjk_artifact", FinalText: "Уцелевшая часть.", + DroppedMembers: 2, DroppedReason: "cjk_artifact"}, + // Every member dropped: nothing ships. The marker must not point at text that is not there. + {Chapter: 4, Disposition: pipeline.DispFlagged, FlagReason: "cjk_artifact", FinalText: "", + DroppedMembers: 3, DroppedReason: "cjk_artifact"}, + // The sanitizer's reason with NO text: the branch order must not print a clean-up of nothing. + {Chapter: 5, Disposition: pipeline.DispFlagged, FlagReason: pipeline.FlagSanitizerStripped, FinalText: ""}, + // The intersection: the EDIT flagged for its own reason AND a member dropped. The marker names + // the hole's cause, never the strip's. + {Chapter: 6, Disposition: pipeline.DispFlagged, FlagReason: pipeline.FlagSanitizerStripped, + FinalText: "Почищенная уцелевшая часть.", DroppedMembers: 1, DroppedReason: "untranslated_echo"}, + }} + var b bytes.Buffer + err := renderTranslate(&b, res, func() (float64, float64, error) { return 0, 0, nil }) + var flagged *pipeline.CompletedWithFlags + if !errors.As(err, &flagged) { + t.Fatalf("a flagged run must return the exit-2 sentinel, got %v", err) + } + out := b.String() + for _, want := range []string{ + "=== CHAPTER 1 CHUNK 0 — ok ===\nЧистый текст.", + "[FLAG sanitizer_stripped — leak cleaned, exported cleaned, verify] ↓\nОчищенный текст.", + "[FLAG cjk_artifact] ↓\n[⚠ TEXT MISSING — 2 source fragments of this unit could not be translated and are NOT in the text below: cjk_artifact]\nУцелевшая часть.", + "=== CHAPTER 4 CHUNK 0 — flagged(cjk_artifact) ===\n[FLAG cjk_artifact] chunk not translated", + "=== CHAPTER 5 CHUNK 0 — flagged(sanitizer_stripped) ===\n[FLAG sanitizer_stripped] chunk not translated", + "[⚠ TEXT MISSING — 1 source fragment of this unit could not be translated and is NOT in the text below: untranslated_echo]\nПочищенная уцелевшая часть.", + } { + if !strings.Contains(out, want) { + t.Fatalf("translate must contain\n%q\ngot:\n%s", want, out) + } + } + if n := strings.Count(out, "TEXT MISSING"); n != 2 { + t.Fatalf("exactly two units ship INCOMPLETE text, got %d markers:\n%s", n, out) + } + if strings.Contains(out, "could not be translated and is NOT in the text below: sanitizer_stripped") { + t.Fatalf("the marker named the unit's own flag reason as the cause of the loss:\n%s", out) + } +} diff --git a/backend/cmd/tmctl/render.go b/backend/cmd/tmctl/render.go index 6312b4a5..99ba614b 100644 --- a/backend/cmd/tmctl/render.go +++ b/backend/cmd/tmctl/render.go @@ -23,6 +23,49 @@ import ( // The ledger callbacks preserve the EXACT order "print → read SpentUSD → print" // of the original code: hoisting the read above the render would change the partial output on error. +// gapMarker is the IN-TEXT marker for a unit that ships text with a piece MISSING — the c-lite +// member drop, where the editor edited the unit's clean members and left a flagged member's text +// out entirely. Without it the reader gets a seamless concatenation with a chunk-sized hole in it +// and no way to know: the hole has no seam, because the editor rewrote the remainder around it. +// +// WHY IT LIVES IN THE RENDERER and not in the export projection, where the deterministic chapter +// title is applied. The title is part of the BOOK and must reach the platform; this marker is +// metadata ABOUT the text, and the wire decides `translated` vs `withheld` by asking whether the +// unit's text is EMPTY (runevents UnitDone.Shipped ← oc.FinalText != ""). A marker written into +// that text would turn every withheld unit into a translated one and break the very distinction +// the contract promises. So it is applied one layer further out, where nothing but a human reads. +// +// WHY IT IS NOT TARGET-LANGUAGE TEXT. Every banner on this surface is English operator vocabulary +// («leak cleaned, verify», «not translated, flagged for a human»), and this surface is an AUDIT +// concatenation — it interleaves per-unit banners with prose, so it is read by the operator, not +// sold to a reader. Keeping the marker in that same vocabulary makes it pair-independent by +// construction: a target language with no langpack at all gets byte-identical output, and there is +// no per-pair string to forget. A target-language marker would belong to the reader-facing artifact +// the PLATFORM builds, and that is not this file. +// +// ⚠ THE GUARD AT BOTH CALL SITES (FinalText != "") IS LOAD-BEARING. Members are counted whenever they +// drop, INCLUDING when every member of the unit drops — and then the unit ships nothing at all. Saying +// «a fragment is missing from the text below» over an empty body would be a second false statement, +// pointing at text that does not exist; the honest banner there is the one that already existed, +// «not translated, flagged for a human». The marker is for a unit that ships SOME of its text. +// ⚠ THE REASON IS THE DROP'S OWN, never the unit's FlagReason. A unit whose edit flagged for a +// cosmetic sanitizer strip AND also lost a member carries the STRIP as its FlagReason; printing that +// as the cause of the hole tells the reader a clean-up ate a chunk of the book — the same false claim +// the banner used to make, moved into the prose. The banner still shows the unit's verdict; the marker +// shows the hole. They are different facts and are now carried by different fields. +func gapMarker(dropped int, reason string) string { + frag, verb := "fragment", "is" + if dropped != 1 { + frag, verb = "fragments", "are" + } + m := fmt.Sprintf("[⚠ TEXT MISSING — %d source %s of this unit could not be translated and %s NOT in the text below", + dropped, frag, verb) + if reason != "" { + m += ": " + reason + } + return m + "]" +} + // renderTranslate prints the per-chunk translation report and returns the // CompletedWithFlags sentinel when chunks were flagged (exit 2). func renderTranslate(w io.Writer, res *pipeline.BookResult, ledger func() (committed, reserved float64, err error)) error { @@ -31,12 +74,26 @@ func renderTranslate(w io.Writer, res *pipeline.BookResult, ledger func() (commi switch { case ch.Disposition == pipeline.DispOK: fmt.Fprintln(w, ch.FinalText) - case ch.FinalText != "": + case ch.DroppedMembers > 0 && ch.FinalText != "": + // c-lite member drop: the editor shipped the CLEAN members and left a flagged member's + // text out. The text below is real and paid for, but it is INCOMPLETE, and saying + // «leak cleaned» here — which this branch used to do for every flagged-with-text unit, + // whatever the reason — told the reader the opposite of what happened. + fmt.Fprintf(w, "[FLAG %s] ↓\n", ch.FlagReason) + fmt.Fprintln(w, gapMarker(ch.DroppedMembers, string(ch.DroppedReason))) + fmt.Fprintln(w, ch.FinalText) + case ch.FlagReason == pipeline.FlagSanitizerStripped && ch.FinalText != "": // Cosmetic sanitizer strip (D35.4a): the leak was removed and the remainder exported, // but the chunk stays flagged for a human to verify the auto-clean — not lost to an // empty placeholder (ch5/ch20 chapter openers used to drop whole for a leading «###»). fmt.Fprintf(w, "[FLAG %s — leak cleaned, exported cleaned, verify] ↓\n", ch.FlagReason) fmt.Fprintln(w, ch.FinalText) + case ch.FinalText != "": + // Flagged, with text, and neither of the two known causes. The engine produces no such + // unit today; printing a neutral banner keeps an unforeseen one from inheriting either + // of the specific claims above. + fmt.Fprintf(w, "[FLAG %s — verify] ↓\n", ch.FlagReason) + fmt.Fprintln(w, ch.FinalText) default: fmt.Fprintf(w, "[FLAG %s] chunk not translated — draft/edit unusable, flagged for a human\n", ch.FlagReason) } @@ -479,8 +536,25 @@ func renderExport(w io.Writer, exp *pipeline.BookExport, asPlaintext bool) error if asPlaintext { // Manifest/drift summary first (F3/F4): a partial or drifted book is EXPLICIT, not silently // exported as complete. - fmt.Fprintf(w, "# export %s — total units=%d, exported=%d, pending=%d", - exp.BookID, exp.TotalUnits, exp.TotalUnits-exp.PendingUnits, exp.PendingUnits) + // The header counts WITHHELD units separately. It used to fold them into `exported` + // (exported = total − pending), so a book that shipped nothing for two units still + // announced them as exported — the same lie as the mislabelled banner below, told in + // numbers: a reader who trusts the summary never learns to look. + withheld, incomplete := 0, 0 + for _, ce := range exp.Chunks { + switch { + case ce.Disposition == "pending": + case ce.FinalText == "": + withheld++ + case ce.DroppedMembers > 0: + incomplete++ + } + } + // `incomplete` is a SUBSET of `exported`, not a fourth part of the total: those units DID ship + // text, with a piece of it missing. Spelled that way so a reader adding the numbers up is not + // misled into thinking they partition the book. + fmt.Fprintf(w, "# export %s — total units=%d, exported=%d (of them incomplete=%d), withheld=%d, pending=%d", + exp.BookID, exp.TotalUnits, exp.TotalUnits-exp.PendingUnits-withheld, incomplete, withheld, exp.PendingUnits) if exp.GhostRows > 0 { fmt.Fprintf(w, ", ghost-rows-dropped=%d", exp.GhostRows) } @@ -494,10 +568,22 @@ func renderExport(w io.Writer, exp *pipeline.BookExport, asPlaintext bool) error fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — pending (not yet translated) ===\n", ce.Chapter, ce.ChunkIdx) case ce.Disposition == string(pipeline.DispOK): fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d ===\n", ce.Chapter, ce.ChunkIdx) - case ce.FinalText != "": + case ce.DroppedMembers > 0 && ce.FinalText != "": + // c-lite member drop: real text, but a member chunk's worth of it is MISSING. The + // banner says so and the marker repeats it INSIDE the text stream, because a reader + // scrolling prose passes the banner once and the hole has no seam of its own. + fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s (INCOMPLETE) ===\n", + ce.Chapter, ce.ChunkIdx, ce.Disposition, flagParen(ce.FlagReason)) + fmt.Fprintln(w, gapMarker(ce.DroppedMembers, ce.DroppedReason)) + case ce.FlagReason == string(pipeline.FlagSanitizerStripped) && ce.FinalText != "": // Cosmetic sanitizer strip (D35.4a): auto-cleaned remainder, flagged for a human. fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s (leak cleaned, verify) ===\n", ce.Chapter, ce.ChunkIdx, ce.Disposition, flagParen(ce.FlagReason)) + case ce.FinalText != "": + // Flagged, with text, neither known cause — a neutral banner rather than an + // inherited claim (see renderTranslate for the same reasoning). + fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s (verify) ===\n", + ce.Chapter, ce.ChunkIdx, ce.Disposition, flagParen(ce.FlagReason)) default: // Substantive flag / upstream skip: no export text (D2 — contaminated output never ships). fmt.Fprintf(w, "=== CHAPTER %d CHUNK %d — %s%s (not translated, flagged for a human) ===\n", diff --git a/backend/cmd/tmmutate/mutations.json b/backend/cmd/tmmutate/mutations.json index 7ebebd64..61f892a2 100644 --- a/backend/cmd/tmmutate/mutations.json +++ b/backend/cmd/tmmutate/mutations.json @@ -211,13 +211,13 @@ }, { "id": "P-alias-judged-on-the-set", - "why": "an inert decline is judged AFTER the fold, on the result of the whole call: the term that owns the alias may be approved by another decision of the same set", + "why": "an inert decline is judged AFTER the fold, on the result of the whole call: the term that owns the alias may be approved by another decision of the same set (anchor re-pointed when refuseInertDeclines gained its ApplyInput argument; the attacked property — the ORDER of the two phases — is unchanged)", "package": "./internal/membank/", "edits": [ { "file": "internal/membank/decisions.go", - "find": "\tfoldAccepted(in, rs, &res)\n\trefuseInertDeclines(rs, &res)", - "replace": "\trefuseInertDeclines(rs, &res)\n\tfoldAccepted(in, rs, &res)" + "find": "\tfoldAccepted(in, rs, &res)\n\trefuseInertDeclines(in, rs, &res)", + "replace": "\trefuseInertDeclines(in, rs, &res)\n\tfoldAccepted(in, rs, &res)" } ] }, @@ -427,13 +427,13 @@ }, { "id": "AJ-seed-alias-decline", - "why": "a decline of a seed ALIAS is inert only while the delta has no row of its own for that surface; when it does, the decline drops it and repairs the collision the report is listing", + "why": "a decline of a seed ALIAS is inert only while the delta has no row of its own for that surface; when it does, the decline drops it and repairs the collision the report is listing (anchor re-pointed when the refusal gained its second carve-out; the attacked property is unchanged)", "package": "./internal/membank/", "edits": [ { "file": "internal/membank/decisions.go", - "find": "held && !deltaHoldsSurface(in.Delta, r.key.Src) {", - "replace": "held {" + "find": "held &&\n\t\t\t!deltaHoldsSurface(in.Delta, r.key.Src) && !rejectsHoldSurface(in.Rejects, r.key.Src) {", + "replace": "held && !rejectsHoldSurface(in.Rejects, r.key.Src) {" } ] }, @@ -724,5 +724,161 @@ "replace": "\t\t// The POST-state, re-read from disk: for an outcome that touched the world the report describes\n\t\t// the files as they now are, never the bytes the call intended.\n\t\trep.Signature = signatureState(book, st.seed.Terms, docsFromResult(st, res))" } ] + }, + { + "id": "SH1-system-join", + "why": "an endpoint that carries ONE system message must receive the memory-bank injection INSIDE that message; without the join the glossary is dropped by the provider at HTTP 200 and no gate can see it", + "package": "./internal/llm/", + "edits": [ + { + "file": "internal/llm/httpllm.go", + "find": "\tif mode != SystemMessagesSingle {", + "replace": "\tif true {" + } + ] + }, + { + "id": "SH2-system-axis-from-yaml", + "why": "the declared quirk has to survive the trip from models.yaml to the resolved Capability; dropped there, the join never runs and the loss is silent again", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/config/models.go", + "find": "\tdefault:\n\t\tc.SystemMessages = llm.SystemMessagesMode(cfg.SystemMessages)\n\t}", + "replace": "\tdefault:\n\t}" + } + ] + }, + { + "id": "SH3-gap-marker", + "why": "a unit that ships text with a member chunk MISSING must say so in the text a human reads; without the marker the reader gets a seamless concatenation with a hole and no seam", + "package": "./cmd/tmctl/", + "edits": [ + { + "file": "cmd/tmctl/render.go", + "find": "\tm := fmt.Sprintf(\"[⚠ TEXT MISSING — %d source %s of this unit could not be translated and %s NOT in the text below\",", + "replace": "\tm := fmt.Sprintf(\"[%d %s %s\"," + } + ] + }, + { + "id": "SH4-dropped-members-counted", + "why": "the incompleteness fact is carried by DroppedMembers, not inferred from a flag reason; stop counting it and both renderers go back to guessing, which is how the reader was told a member drop was a cosmetic clean-up", + "package": "./cmd/tmctl/", + "edits": [ + { + "file": "internal/pipeline/export.go", + "find": "\tce.DroppedMembers = len(drops)", + "replace": "\tce.DroppedMembers = 0" + } + ] + }, + { + "id": "SH5-decline-converges", + "why": "a decline already on the record is a decision being RE-SENT, not one being made; without this carve-out the door refuses the identical document forever and a worker that retries splits the state", + "package": "./internal/membank/", + "edits": [ + { + "file": "internal/membank/decisions.go", + "find": " && !rejectsHoldSurface(in.Rejects, r.key.Src) {", + "replace": " {" + } + ] + }, + { + "id": "SH6-rejects-rename-first", + "why": "the delta is the document the seed-conflict refusal READS, so it must not be the one that lands first: a decline interrupted after a delta-first rename leaves a state no predicate can tell from an inert decline, and the re-send is refused forever", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/bankdecisions.go", + "find": "\tif rejectsStage != nil {\n\t\tif err := rejectsStage.commit(); err != nil {\n\t\t\tif deltaStage != nil {\n\t\t\t\tdeltaStage.abort()\n\t\t\t}\n\t\t\treturn wrote, err\n\t\t}\n\t\twrote.rejects = true\n\t}\n\tif deltaStage != nil {\n\t\tif err := deltaStage.commit(); err != nil {\n\t\t\treturn wrote, err\n\t\t}\n\t\twrote.delta = true\n\t}", + "replace": "\tif deltaStage != nil {\n\t\tif err := deltaStage.commit(); err != nil {\n\t\t\tif rejectsStage != nil {\n\t\t\t\trejectsStage.abort()\n\t\t\t}\n\t\t\treturn wrote, err\n\t\t}\n\t\twrote.delta = true\n\t}\n\tif rejectsStage != nil {\n\t\tif err := rejectsStage.commit(); err != nil {\n\t\t\treturn wrote, err\n\t\t}\n\t\twrote.rejects = true\n\t}" + } + ] + }, + { + "id": "SH7-drop-reason-is-the-holes-own", + "why": "the marker must name why the MEMBER dropped, not the unit's flag reason: a unit whose edit flagged for a cosmetic strip AND lost a member would otherwise tell the reader a clean-up ate a chunk of the book — the same false claim the pack removes, moved into the prose", + "package": "./cmd/tmctl/", + "edits": [ + { + "file": "cmd/tmctl/render.go", + "find": "gapMarker(ce.DroppedMembers, ce.DroppedReason)", + "replace": "gapMarker(ce.DroppedMembers, ce.FlagReason)" + } + ] + }, + { + "id": "SH8-draft-only-has-no-members", + "why": "a draft-only pipeline makes every chunk a SINGLETON unit whose own draft row is its final row, so the member-drop rule reads the unit's own flag as a lost member; without the guard a cosmetically stripped chunk that lost NOTHING is announced as incomplete — a marker that misinforms, which the order forbids as firmly as the silence it replaces", + "package": "./cmd/tmctl/", + "edits": [ + { + "file": "internal/pipeline/export.go", + "find": "\tif r.finalStageWave() == waveEdit {", + "replace": "\tif true {" + } + ] + }, + { + "id": "SH9-shipped-gemini-declaration", + "why": "the two lines in configs/models.yaml are the ONLY thing that makes the system-message join reach the real endpoint; everything else about the fix is exercised against a synthetic fixture provider, and deleting them used to leave the whole module green", + "package": "./internal/config/", + "edits": [ + { + "file": "configs/models.yaml", + "find": " capabilities:\n system_messages: single\n", + "replace": "" + } + ] + }, + { + "id": "SH10-translate-empty-text-guard", + "why": "renderTranslate's non-empty-text guards are what stop the gap marker pointing at text that does not exist; the code calls them load-bearing and nothing pinned them", + "package": "./cmd/tmctl/", + "edits": [ + { + "file": "cmd/tmctl/render.go", + "find": "\t\tcase ch.DroppedMembers > 0 && ch.FinalText != \"\":", + "replace": "\t\tcase ch.DroppedMembers > 0:" + } + ] + }, + { + "id": "SH11-explicit-multi-costs-nothing", + "why": "writing the DEFAULT out loud must not put a key in the capability the snapshot carries — otherwise a line that changes no byte on the wire re-buys the book", + "package": "./internal/config/", + "edits": [ + { + "file": "internal/config/models.go", + "find": "\tcase \"multi\":\n\t\tc.SystemMessages = llm.SystemMessagesMulti", + "replace": "\tcase \"multi\":\n\t\tc.SystemMessages = llm.SystemMessagesMode(\"multi\")" + } + ] + }, + { + "id": "SH12-declined-never-enters-the-bank", + "why": "the rejects-first rename order leaves the delta row on disk so an interrupted decline can converge; without this filter that window is one in which a PAID run injects a term the owner explicitly declined", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/mining.go", + "find": "\t\t\tif rejects[text.NormalizeSourceKey(e.Src)] {", + "replace": "\t\t\tif false {" + } + ] + }, + { + "id": "SH13-inert-decline-second-door", + "why": "the ALIAS door refuses an inert decline for its own good reason, but a decline ALREADY on the record is a decision being re-sent; unnarrowed it refused the standing ledger forever and, the layer being all-or-nothing, discarded the lawful decisions sent beside it", + "package": "./internal/membank/", + "edits": [ + { + "file": "internal/membank/decisions.go", + "find": "\t\tif rejectsHoldSurface(in.Rejects, r.key.Src) {\n\t\t\tcontinue\n\t\t}\n", + "replace": "" + } + ] } -] \ No newline at end of file +] diff --git a/backend/configs/models.yaml b/backend/configs/models.yaml index 08bd745e..7bd24e2e 100644 --- a/backend/configs/models.yaml +++ b/backend/configs/models.yaml @@ -120,6 +120,21 @@ providers: # total_tokens−prompt−completion (live-проба 2026-07-10: completion=2, total=847 → 823 thinking). # Адаптер деривит их из total и биллит по output — иначе mandatory-thinking апекс слепил бы потолок. reasoning: additive_total + # ⚠️ ОДНО системное сообщение на запрос. Конвейер строит ДВА (базовый промпт + инъекция банка + # памяти, render.go MessagesWithInjection), и этот слой второе не проносит: инъекция — + # глоссарий — пропадала БЕЗ ошибки, ответ приходил 200 и выглядел правильным переводом. + # ВЕНДОР-ОСНОВАНИЕ (ai.google.dev/api/generate-content, страница помечена 2026-08-17, снято + # 2026-08-28): в нативном GenerateContentRequest поле `systemInstruction` — ОДИН + # `object (Content)` (в той же таблице `contents[]` и `tools[]` несут суффикс повторяемого + # поля `[]`), а `Content.role` документирован как «Must be either 'user' or 'model'» ⇒ второму + # системному ходу в нативном запросе места нет вовсе. Что делает с ним OpenAI-совместимый + # шим, вендор НЕ документирует нигде (раздел «Current limitations» молчит и в живой странице, + # и в снимке 2026-08-21). Наша проба ($0, полигон 22.08) устанавливает, что они НЕ + # склеиваются и что инструкция ПЕРВОГО не исполняется; выживает ли второй — открыто и для + # решения безразлично: при любом чтении, кроме «склеиваются», два системных теряют текст. + # Поэтому склеиваем сами — это единственная форма, которая потерять не может. + capabilities: + system_messages: single timeouts: { attempt_s: 300, max_attempts: 3, backoff_cap_s: 60 } # апекс думает дольше openai: # OpenAI прямой ключ (D3: Anthropic убран, OpenAI ОСТАЁТСЯ). gpt-5-nano альт-черновик, diff --git a/backend/internal/config/models.go b/backend/internal/config/models.go index 93f0a416..22a3d938 100644 --- a/backend/internal/config/models.go +++ b/backend/internal/config/models.go @@ -98,6 +98,11 @@ type CapabilitiesConfig struct { // below this minimum. 0 (unset) = inherit / no floor. Schema, not a comment — // the Kimi≥16k / Gemini≥8k / DeepSeek≥8k min-budgets that were prose notes. MinMaxTokens int `yaml:"min_max_tokens"` + // SystemMessages is the endpoint's system-message cardinality: "" (inherit / multi) or + // "single" for an endpoint that carries exactly ONE system message. Declared on the PROVIDER + // as a rule, because it is a property of the endpoint's request translation and not of a + // model's talent — every model behind the same base_url shares it. + SystemMessages string `yaml:"system_messages"` } // TemperatureCap declares how temperature reaches the wire. @@ -236,6 +241,15 @@ func LoadModels(path string) (*Models, error) { if p.Kind == "local" && p.Model == "" { bad("provider %s: local kind requires model (its own tag)", name) } + // The Anthropic adapter takes NO Capability at all (clients.go builds it without one), so a + // wire-shape declaration there is inert — and worse than inert: it still resolves, still + // marshals into the capability the job snapshot carries, and therefore still re-buys the book + // for a line that changes no byte on the wire. Refused by KIND, the way cache_ttl is refused + // on the kinds that cannot use it. Named for system_messages because that is the axis whose + // silent no-op would restore the exact defect it exists to close. + if p.Kind == "anthropic" && p.Capabilities != nil && p.Capabilities.SystemMessages != "" { + bad("provider %s: capabilities.system_messages is an OpenAI-compat wire shape and the anthropic adapter takes no capability — it would change nothing on the wire and still move the snapshot. Drop it (the Messages API carries system as its own blocks)", name) + } if p.LegacyPermissive != nil { bad("provider %s: `permissive:` is retired — declare WHICH content labels this endpoint may receive: `accepts_labels: [