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
This commit is contained in:
parent
58bae309c0
commit
7d0c6f2f54
29 changed files with 2049 additions and 58 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
382
backend/cmd/tmctl/exportgap_cli_test.go
Normal file
382
backend/cmd/tmctl/exportgap_cli_test.go
Normal file
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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": ""
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
]
|
||||
|
|
|
|||
|
|
@ -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 альт-черновик,
|
||||
|
|
|
|||
|
|
@ -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: [<label>]` (a book's content_labels must be a subset of it, D39.25/D39.26). Drop the flag", name)
|
||||
}
|
||||
|
|
@ -558,6 +572,18 @@ func applyCapConfig(c *llm.Capability, cfg *CapabilitiesConfig) {
|
|||
if cfg.MinMaxTokens > 0 { // 0 = unset → inherit the provider/kind floor (model wins when it declares one)
|
||||
c.MinMaxTokens = cfg.MinMaxTokens
|
||||
}
|
||||
// "" = unset → inherit (the multi baseline, or the provider's declaration). An explicit
|
||||
// "multi" NORMALISES to the zero value: it is how a model says "not this provider's single-
|
||||
// slot rule" and how an author records a verified endpoint, and it must cost nothing —
|
||||
// resolving it to a distinct string would put a key in the snapshot and re-buy the book for
|
||||
// a line that changed no byte on the wire.
|
||||
switch cfg.SystemMessages {
|
||||
case "":
|
||||
case "multi":
|
||||
c.SystemMessages = llm.SystemMessagesMulti
|
||||
default:
|
||||
c.SystemMessages = llm.SystemMessagesMode(cfg.SystemMessages)
|
||||
}
|
||||
}
|
||||
|
||||
// canonicalLabel reports whether a label is written the way LoadBook normalises a book's labels
|
||||
|
|
@ -626,6 +652,11 @@ func validateCapabilities(bad func(string, ...any), where string, c *Capabilitie
|
|||
default:
|
||||
bad("%s: capabilities.budget_field must be max_tokens|max_completion_tokens, got %q", where, c.BudgetField)
|
||||
}
|
||||
switch c.SystemMessages {
|
||||
case "", "multi", "single":
|
||||
default:
|
||||
bad("%s: capabilities.system_messages must be multi|single, got %q", where, c.SystemMessages)
|
||||
}
|
||||
if c.Temperature != nil {
|
||||
switch c.Temperature.Mode {
|
||||
case "send", "omit", "force":
|
||||
|
|
|
|||
|
|
@ -1,7 +1,14 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"textmachine/backend/internal/llm"
|
||||
)
|
||||
|
||||
// models_catalog_test.go — the pack-12 point-1 catalog validation test (research/21
|
||||
|
|
@ -71,3 +78,110 @@ func TestShippedModelsCatalogValid(t *testing.T) {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestShippedGeminiDeclaresOneSystemMessage is the gate the pack's own fix was missing: the two lines
|
||||
// in configs/models.yaml are the ONLY thing that makes the system-message join reach the real endpoint,
|
||||
// and an adversarial pass proved that deleting them left the entire module's tests green. Everything
|
||||
// else about the fix is exercised against a synthetic fixture provider; this is what ties it to
|
||||
// production. The failure it guards is a silent HTTP 200 with the glossary dropped — nothing 4xx, no
|
||||
// gate, a plausible translation that has quietly stopped obeying the memory bank.
|
||||
func TestShippedGeminiDeclaresOneSystemMessage(t *testing.T) {
|
||||
m, err := LoadModels(shippedModelsYAML)
|
||||
if err != nil {
|
||||
t.Fatalf("shipped configs/models.yaml failed validation:\n%v", err)
|
||||
}
|
||||
seen := 0
|
||||
for name, mod := range m.Models {
|
||||
if mod.Provider != "gemini" {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
if got := m.ResolveCapability(name).SystemMessages; got != llm.SystemMessagesSingle {
|
||||
t.Errorf("model %s rides the Gemini OpenAI-compat layer, which carries ONE system message; "+
|
||||
"resolved SystemMessages = %q, want %q — the memory-bank injection is the second system "+
|
||||
"message and is dropped by the endpoint without an error",
|
||||
name, got, llm.SystemMessagesSingle)
|
||||
}
|
||||
}
|
||||
if seen == 0 {
|
||||
t.Fatal("no model resolves to the gemini provider — the declaration this test guards has nothing to guard; " +
|
||||
"if the provider was removed on purpose, remove this test with it")
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemMessagesIsRefusedOnTheAnthropicKind: the axis is an OpenAI-compat wire shape, and the
|
||||
// Anthropic adapter is built with no Capability at all — so a declaration there reaches nothing, while
|
||||
// still resolving into the capability the job snapshot carries. Accepted silently it would re-buy a
|
||||
// book for a line that changed no byte on the wire; refused by kind, the mistake is a load error.
|
||||
func TestSystemMessagesIsRefusedOnTheAnthropicKind(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "models.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
prices_checked: "`+time.Now().UTC().Format("2006-01-02")+`"
|
||||
default_model: m
|
||||
providers:
|
||||
a:
|
||||
kind: anthropic
|
||||
api_key_env: X
|
||||
capabilities: { system_messages: single }
|
||||
models:
|
||||
m:
|
||||
provider: a
|
||||
price: { input_per_m: 1, cached_per_m: 1, cache_write_per_m: 0, output_per_m: 1 }
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := LoadModels(path)
|
||||
if err == nil {
|
||||
t.Fatal("a wire-shape axis the anthropic adapter cannot read must be refused, not silently resolved")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "capabilities.system_messages") {
|
||||
t.Fatalf("the refusal must name the key, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestExplicitMultiCostsNothing pins the normalisation the code argues for: writing the DEFAULT out
|
||||
// loud is how an author records a verified endpoint or overrides a provider's single-slot rule, and it
|
||||
// must not put a key in the capability the snapshot carries — that would re-buy the book for a line
|
||||
// that changed no byte on the wire.
|
||||
func TestExplicitMultiCostsNothing(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
write := func(caps string) *Models {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, strings.ReplaceAll(caps, " ", "")+"models.yaml")
|
||||
if err := os.WriteFile(path, []byte(`
|
||||
prices_checked: "`+time.Now().UTC().Format("2006-01-02")+`"
|
||||
default_model: m
|
||||
providers:
|
||||
p:
|
||||
kind: openai
|
||||
base_url: http://x
|
||||
api_key_env: X
|
||||
`+caps+`
|
||||
models:
|
||||
m:
|
||||
provider: p
|
||||
price: { input_per_m: 1, cached_per_m: 1, cache_write_per_m: 0, output_per_m: 1 }
|
||||
`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
m, err := LoadModels(path)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
bare := write("")
|
||||
multi := write(" capabilities: { system_messages: multi }\n")
|
||||
single := write(" capabilities: { system_messages: single }\n")
|
||||
|
||||
bareJSON, _ := json.Marshal(bare.ResolveCapability("m"))
|
||||
multiJSON, _ := json.Marshal(multi.ResolveCapability("m"))
|
||||
singleJSON, _ := json.Marshal(single.ResolveCapability("m"))
|
||||
if string(bareJSON) != string(multiJSON) {
|
||||
t.Fatalf("an explicit `multi` must resolve byte-identically to saying nothing:\n bare %s\n multi %s", bareJSON, multiJSON)
|
||||
}
|
||||
if string(singleJSON) == string(bareJSON) {
|
||||
t.Fatalf("a declared `single` MUST move the snapshot bytes — that is what makes the flip a loud --resnapshot: %s", singleJSON)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,46 @@ const (
|
|||
ReasoningMandatory ReasoningControl = "mandatory"
|
||||
)
|
||||
|
||||
// SystemMessagesMode declares how many SYSTEM messages the endpoint carries.
|
||||
//
|
||||
// It is a wire-shape fact about the ENDPOINT, not about the model's talent, which is why it
|
||||
// belongs here and not in the assembler: the pipeline builds a stable system prefix plus its own
|
||||
// memory-bank injection message (render.go MessagesWithInjection), and an endpoint that carries
|
||||
// only one of them drops the injection — the glossary — while answering 200 with a plausible
|
||||
// translation. That is the silent class this axis exists to close: nothing fails, the book just
|
||||
// stops being consistent, and no downstream gate can tell the difference.
|
||||
type SystemMessagesMode string
|
||||
|
||||
const (
|
||||
// SystemMessagesMulti is the OpenAI-compat baseline: system messages go on the wire
|
||||
// one-for-one, in the order the assembler produced them. Zero value, so a provider that
|
||||
// declares nothing keeps exactly the wire it has today AND marshals byte-identically —
|
||||
// declaring this axis on ONE provider does not move anybody else's snapshot.
|
||||
SystemMessagesMulti SystemMessagesMode = ""
|
||||
// SystemMessagesSingle is for an endpoint that accepts exactly ONE system message and does
|
||||
// not document what it does with the rest. The leading system run is JOINED into one
|
||||
// message, in order, before it reaches the wire.
|
||||
//
|
||||
// The vendor fact this is declared from (Gemini, generativelanguage OpenAI-compat layer;
|
||||
// ai.google.dev/api/generate-content, page stamped 2026-08-17, read 2026-08-28): the native
|
||||
// GenerateContentRequest carries `systemInstruction` as a SINGLE `object (Content)` — the
|
||||
// same field table writes `contents[]` and `tools[]` with the repeated-field `[]` suffix —
|
||||
// and `Content.role` is documented "Must be either 'user' or 'model'". So the native request
|
||||
// has exactly one system slot and a second system TURN is not representable at all. What the
|
||||
// compat shim does with a second one the vendor does not say anywhere (the "Current
|
||||
// limitations" section is silent, in the live page and in the 2026-08-21 snapshot alike);
|
||||
// our own $0 probe establishes only that they are NOT concatenated and that the FIRST one's
|
||||
// instruction is not executed. Whether the second survives stays open — and does not matter
|
||||
// here: under every reading except "concatenated", sending two loses content, and joining
|
||||
// them ourselves is the one form that cannot.
|
||||
SystemMessagesSingle SystemMessagesMode = "single"
|
||||
)
|
||||
|
||||
// systemJoinSeparator is what SystemMessagesSingle joins the system run with: a blank line — the
|
||||
// same seam ApplyHeading uses, and the one the vendor's own note describes for a multi-part
|
||||
// system Content ("content in each part will be in a separate paragraph").
|
||||
const systemJoinSeparator = "\n\n"
|
||||
|
||||
// ReasoningCap is the resolved reasoning wire-form for one model.
|
||||
type ReasoningCap struct {
|
||||
Control ReasoningControl
|
||||
|
|
@ -115,6 +155,25 @@ type Capability struct {
|
|||
// floor-less model's snapshot byte-identical to before this field existed. 0 =
|
||||
// no floor (reasoning-off models: GLM, grok).
|
||||
MinMaxTokens int `json:",omitempty"`
|
||||
// SystemMessages is the endpoint's system-message cardinality (default: multi). It is folded
|
||||
// into the job snapshot with everything else here, and for this axis that fold is not a
|
||||
// formality: the join happens BELOW RequestHash (the hash is taken over the neutral message
|
||||
// list in render.go; the join in toOpenAIMessages), so where the snapshot does not reach,
|
||||
// nothing at all notices that a stored checkpoint now stands for different bytes. omitempty
|
||||
// keeps a provider that does not declare it byte-identical to before this field existed.
|
||||
//
|
||||
// ⚠ WHERE THE FOLD DOES NOT REACH, said plainly rather than assumed away (adversarial review).
|
||||
// The snapshot carries the wire of the STAGE models and the single escalate_to hop; the
|
||||
// TERMINOLOGY GATE's model is deliberately not snapshot-folded (config/pipeline.go, the gate's
|
||||
// own note), and that role DOES send two system messages. So flipping this axis on a provider
|
||||
// that only the terminology gate uses changes that call's bytes under an unchanged
|
||||
// RequestHash, and its stored checkpoints keep replaying. The hole is not this axis's — every
|
||||
// capability axis (budget field, temperature mode, reasoning control) has ridden it since D3.1
|
||||
// — but this axis is the first whose whole purpose is to change those bytes, so it is named
|
||||
// here. Closing it means folding the gate's model wire the way repairSnapshot folds the repair
|
||||
// model's; that is a snapshot-contract change and belongs to whoever owns the gate's
|
||||
// not-folded decision, not to a provider quirk.
|
||||
SystemMessages SystemMessagesMode `json:",omitempty"`
|
||||
}
|
||||
|
||||
// withDefaults fills the OpenAI-compat baseline for zero fields: max_tokens +
|
||||
|
|
|
|||
|
|
@ -513,13 +513,50 @@ func retryAfterOf(err error) time.Duration {
|
|||
// toOpenAIMessages maps neutral messages onto the wire. CacheBoundary is
|
||||
// meaningless here: OpenAI-compatible caches (DeepSeek: prefix-match from token
|
||||
// 0 in blocks of 64 tokens) are automatic; the stable-prefix ORDER the
|
||||
// assembler produced is all that matters.
|
||||
func toOpenAIMessages(msgs []Message) []openAIMessage {
|
||||
out := make([]openAIMessage, len(msgs))
|
||||
for i, m := range msgs {
|
||||
out[i] = openAIMessage{Role: m.Role, Content: m.Content}
|
||||
// assembler produced is all that matters — which is also why JOINING the system
|
||||
// run under SystemMessagesSingle costs no cache: the stable prefix keeps its
|
||||
// bytes and its position, it merely stops being its own message.
|
||||
//
|
||||
// mode is the endpoint's system-message cardinality (Capability.SystemMessages).
|
||||
// Under SystemMessagesSingle the LEADING system run is joined into one message so
|
||||
// an endpoint with one system slot receives the memory-bank injection instead of
|
||||
// silently dropping it; a system message AFTER a non-system turn is refused loud,
|
||||
// exactly as the Anthropic adapter refuses it — our assembler never produces one,
|
||||
// and a join that quietly re-ordered a conversation would be a worse lie than the
|
||||
// one this function exists to stop. Under SystemMessagesMulti (the default) the
|
||||
// mapping is one-for-one, byte-identical to the wire before this axis existed.
|
||||
func toOpenAIMessages(msgs []Message, mode SystemMessagesMode) ([]openAIMessage, error) {
|
||||
if mode != SystemMessagesSingle {
|
||||
out := make([]openAIMessage, len(msgs))
|
||||
for i, m := range msgs {
|
||||
out[i] = openAIMessage{Role: m.Role, Content: m.Content}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
return out
|
||||
var systemRun []string
|
||||
out := make([]openAIMessage, 0, len(msgs))
|
||||
inSystemPrefix := true
|
||||
for _, m := range msgs {
|
||||
if m.Role == "system" {
|
||||
if !inSystemPrefix {
|
||||
return nil, fmt.Errorf("llm: this endpoint carries a single system message and got one after a non-system turn; joining it would re-order the conversation")
|
||||
}
|
||||
systemRun = append(systemRun, m.Content)
|
||||
continue
|
||||
}
|
||||
if inSystemPrefix {
|
||||
inSystemPrefix = false
|
||||
if len(systemRun) > 0 {
|
||||
out = append(out, openAIMessage{Role: "system", Content: strings.Join(systemRun, systemJoinSeparator)})
|
||||
}
|
||||
}
|
||||
out = append(out, openAIMessage{Role: m.Role, Content: m.Content})
|
||||
}
|
||||
// A message list that is system-only (no user turn) still has to ship its join.
|
||||
if inSystemPrefix && len(systemRun) > 0 {
|
||||
out = append(out, openAIMessage{Role: "system", Content: strings.Join(systemRun, systemJoinSeparator)})
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// jsonResponseFormat returns the response_format value for JSONOnly requests
|
||||
|
|
|
|||
|
|
@ -85,9 +85,15 @@ func (c *localClient) Complete(ctx context.Context, req LLMRequest) (*LLMRespons
|
|||
if c.temp > 0 {
|
||||
temp = c.temp
|
||||
}
|
||||
// Same resolution as the cloud OpenAI-compat path: the local stand rides the identical wire
|
||||
// shape, so it reads the identical axis rather than assuming the default.
|
||||
msgs, err := toOpenAIMessages(req.Messages, c.cap.SystemMessages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.http.complete(ctx, openAIRequest{
|
||||
model: c.model,
|
||||
messages: toOpenAIMessages(req.Messages),
|
||||
messages: msgs,
|
||||
maxTokens: maxTok,
|
||||
temperature: temp,
|
||||
stream: false,
|
||||
|
|
|
|||
|
|
@ -106,9 +106,16 @@ func additiveReasoning(ctx context.Context, log *slog.Logger, provider string, u
|
|||
}
|
||||
|
||||
func (c *openAICompatClient) Complete(ctx context.Context, req LLMRequest) (*LLMResponse, error) {
|
||||
// The system-run join is resolved BEFORE the request is built, and its refusal is returned
|
||||
// unretried: an un-joinable message list is a request-shape error, and retrying it would only
|
||||
// buy the same refusal three times.
|
||||
msgs, err := toOpenAIMessages(req.Messages, c.cap.SystemMessages)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp, err := c.http.complete(ctx, openAIRequest{
|
||||
model: req.Model,
|
||||
messages: toOpenAIMessages(req.Messages),
|
||||
messages: msgs,
|
||||
maxTokens: req.MaxTokens,
|
||||
temperature: req.Temperature,
|
||||
stream: false,
|
||||
|
|
|
|||
233
backend/internal/llm/systemmessages_test.go
Normal file
233
backend/internal/llm/systemmessages_test.go
Normal file
|
|
@ -0,0 +1,233 @@
|
|||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// systemmessages_test.go pins the SystemMessages capability axis — the fix for the silent loss of
|
||||
// the memory-bank injection on an endpoint that carries one system message.
|
||||
//
|
||||
// ⚠ WHAT THESE TESTS MUST ASSERT, and why the obvious test is worthless: the pipeline has ALWAYS
|
||||
// put both system messages in the request, and it does so on unfixed code too — the loss happens
|
||||
// at the PROVIDER, not in our assembler. A test that checks "the injection is in the request" is
|
||||
// therefore green before the fix and proves nothing. What separates fixed from unfixed is the
|
||||
// POST-FIX invariant asserted below: on an endpoint declared single there is EXACTLY ONE system
|
||||
// message on the wire and the injection is INSIDE it.
|
||||
|
||||
// systemRoles returns the roles of a decoded wire body's messages, in order.
|
||||
func systemRoles(t *testing.T, body map[string]any) []string {
|
||||
t.Helper()
|
||||
raw, ok := body["messages"].([]any)
|
||||
if !ok {
|
||||
t.Fatalf("messages absent or not a list: %v", body["messages"])
|
||||
}
|
||||
roles := make([]string, 0, len(raw))
|
||||
for _, m := range raw {
|
||||
roles = append(roles, m.(map[string]any)["role"].(string))
|
||||
}
|
||||
return roles
|
||||
}
|
||||
|
||||
func messageContent(t *testing.T, body map[string]any, i int) string {
|
||||
t.Helper()
|
||||
raw := body["messages"].([]any)
|
||||
return raw[i].(map[string]any)["content"].(string)
|
||||
}
|
||||
|
||||
// captureWire runs one Complete against a stub endpoint and returns the decoded request body.
|
||||
func captureWire(t *testing.T, cap Capability, msgs []Message) map[string]any {
|
||||
t.Helper()
|
||||
var got map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Errorf("decode request: %v", err)
|
||||
}
|
||||
openAIOK(t, w, `{"choices":[{"message":{"content":"ok"},"finish_reason":"stop"}],
|
||||
"usage":{"prompt_tokens":10,"completion_tokens":2,"total_tokens":12}}`)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewOpenAICompatClient(OpenAICompatConfig{
|
||||
Name: "stub", BaseURL: srv.URL, APIKey: "k", Profile: fastProfile(), Cap: cap,
|
||||
}, nil)
|
||||
if _, err := c.Complete(context.Background(), LLMRequest{
|
||||
Model: "m", Messages: msgs, MaxTokens: 4096, Temperature: 0.4,
|
||||
}); err != nil {
|
||||
t.Fatalf("Complete: %v", err)
|
||||
}
|
||||
return got
|
||||
}
|
||||
|
||||
// bankRun is the message list the pipeline assembler actually produces for a chunk whose memory
|
||||
// bank is non-empty (render.go MessagesWithInjection): stable system prefix, injection, user.
|
||||
func bankRun() []Message {
|
||||
return []Message{
|
||||
{Role: "system", Content: "Переводи художественный текст с zh на ru.", CacheBoundary: true},
|
||||
{Role: "system", Content: "ГЛОССАРИЙ: 方源 → Фан Юань"},
|
||||
{Role: "user", Content: "исходный чанк"},
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemMessagesSingleCarriesTheInjection is THE post-fix invariant: one system message, and
|
||||
// the glossary inside it. On unfixed code the wire carries two system messages and this fails.
|
||||
func TestSystemMessagesSingleCarriesTheInjection(t *testing.T) {
|
||||
body := captureWire(t, Capability{SystemMessages: SystemMessagesSingle}, bankRun())
|
||||
|
||||
roles := systemRoles(t, body)
|
||||
nSystem := 0
|
||||
for _, r := range roles {
|
||||
if r == "system" {
|
||||
nSystem++
|
||||
}
|
||||
}
|
||||
if nSystem != 1 {
|
||||
t.Fatalf("a single-system endpoint must receive EXACTLY ONE system message, got %d (roles %v)", nSystem, roles)
|
||||
}
|
||||
if want := []string{"system", "user"}; len(roles) != 2 || roles[0] != want[0] || roles[1] != want[1] {
|
||||
t.Fatalf("roles = %v, want %v (the join must not move the user turn)", roles, want)
|
||||
}
|
||||
sys := messageContent(t, body, 0)
|
||||
if !strings.Contains(sys, "ГЛОССАРИЙ: 方源 → Фан Юань") {
|
||||
t.Fatalf("the memory-bank injection is NOT inside the single system message: %q", sys)
|
||||
}
|
||||
if !strings.Contains(sys, "Переводи художественный текст") {
|
||||
t.Fatalf("the base prompt was lost by the join: %q", sys)
|
||||
}
|
||||
// Order is load-bearing: the stable prefix must stay first, or the prefix cache stops hitting.
|
||||
if strings.Index(sys, "Переводи") > strings.Index(sys, "ГЛОССАРИЙ") {
|
||||
t.Fatalf("the join reversed the stable prefix and the injection: %q", sys)
|
||||
}
|
||||
if want := "Переводи художественный текст с zh на ru.\n\nГЛОССАРИЙ: 方源 → Фан Юань"; sys != want {
|
||||
t.Fatalf("joined system =\n%q\nwant\n%q", sys, want)
|
||||
}
|
||||
if got := messageContent(t, body, 1); got != "исходный чанк" {
|
||||
t.Fatalf("user turn = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemMessagesMultiIsTheWireWeAlreadyShip is the control: an endpoint that declares nothing
|
||||
// keeps the two-message wire byte-for-byte, so declaring the quirk on ONE provider cannot change
|
||||
// what every other provider receives.
|
||||
func TestSystemMessagesMultiIsTheWireWeAlreadyShip(t *testing.T) {
|
||||
for _, cap := range []Capability{{}, {SystemMessages: SystemMessagesMulti}} {
|
||||
body := captureWire(t, cap, bankRun())
|
||||
roles := systemRoles(t, body)
|
||||
if want := []string{"system", "system", "user"}; len(roles) != 3 ||
|
||||
roles[0] != want[0] || roles[1] != want[1] || roles[2] != want[2] {
|
||||
t.Fatalf("roles = %v, want %v", roles, want)
|
||||
}
|
||||
if got := messageContent(t, body, 0); got != "Переводи художественный текст с zh на ru." {
|
||||
t.Fatalf("system[0] = %q", got)
|
||||
}
|
||||
if got := messageContent(t, body, 1); got != "ГЛОССАРИЙ: 方源 → Фан Юань" {
|
||||
t.Fatalf("system[1] = %q", got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemMessagesSingleWithNoInjectionIsIdentical pins that the join is invisible when there is
|
||||
// nothing to join — a bank-less chunk on a single-system endpoint ships exactly what it shipped.
|
||||
func TestSystemMessagesSingleWithNoInjectionIsIdentical(t *testing.T) {
|
||||
msgs := []Message{
|
||||
{Role: "system", Content: "роль", CacheBoundary: true},
|
||||
{Role: "user", Content: "чанк"},
|
||||
}
|
||||
body := captureWire(t, Capability{SystemMessages: SystemMessagesSingle}, msgs)
|
||||
if roles := systemRoles(t, body); len(roles) != 2 || roles[0] != "system" || roles[1] != "user" {
|
||||
t.Fatalf("roles = %v", roles)
|
||||
}
|
||||
if got := messageContent(t, body, 0); got != "роль" {
|
||||
t.Fatalf("a lone system message must be untouched by the join, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemMessagesSingleRefusesMidDialogueSystem: joining a system turn that sits AFTER a user
|
||||
// turn would re-order the conversation, so it fails loud — the same refusal the Anthropic adapter
|
||||
// makes for the same reason. Our assembler never produces one; this keeps that true by force.
|
||||
func TestSystemMessagesSingleRefusesMidDialogueSystem(t *testing.T) {
|
||||
_, err := toOpenAIMessages([]Message{
|
||||
{Role: "system", Content: "роль"},
|
||||
{Role: "user", Content: "чанк"},
|
||||
{Role: "system", Content: "поздний системный"},
|
||||
}, SystemMessagesSingle)
|
||||
if err == nil {
|
||||
t.Fatal("a system message after a non-system turn must be refused, not silently joined")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "single system message") {
|
||||
t.Fatalf("refusal must name the reason, got %v", err)
|
||||
}
|
||||
// The SAME list is legal on a multi endpoint: the refusal is the quirk's, not a new rule.
|
||||
if _, err := toOpenAIMessages([]Message{
|
||||
{Role: "system", Content: "роль"},
|
||||
{Role: "user", Content: "чанк"},
|
||||
{Role: "system", Content: "поздний системный"},
|
||||
}, SystemMessagesMulti); err != nil {
|
||||
t.Fatalf("multi endpoints keep their existing tolerance, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSystemMessagesAxisCannotReachTheAnthropicPath is the ⛔ of the order: Anthropic turns the
|
||||
// system prefix into separate blocks and CacheBoundary into a real cache_control, so a join there
|
||||
// would destroy the cache contract. The axis must not be able to touch it — and structurally it
|
||||
// cannot, because the Anthropic client is built without a Capability at all.
|
||||
func TestSystemMessagesAxisCannotReachTheAnthropicPath(t *testing.T) {
|
||||
var got map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := json.NewDecoder(r.Body).Decode(&got); err != nil {
|
||||
t.Errorf("decode: %v", err)
|
||||
}
|
||||
w.Write([]byte(`{"id":"m","model":"c","content":[{"type":"text","text":"ок"}],"stop_reason":"end_turn",
|
||||
"usage":{"input_tokens":1,"output_tokens":1}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
c := NewAnthropicClient(AnthropicConfig{BaseURL: srv.URL, APIKey: "k", Profile: fastProfile(), CacheTTL: "5m"}, nil)
|
||||
if _, err := c.Complete(context.Background(), LLMRequest{Model: "c", Messages: bankRun(), MaxTokens: 512}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sys, ok := got["system"].([]any)
|
||||
if !ok || len(sys) != 2 {
|
||||
t.Fatalf("Anthropic must still receive TWO system blocks, got %v", got["system"])
|
||||
}
|
||||
first := sys[0].(map[string]any)
|
||||
cc, ok := first["cache_control"].(map[string]any)
|
||||
if !ok || cc["type"] != "ephemeral" || cc["ttl"] != "5m" {
|
||||
t.Fatalf("the CacheBoundary block lost its live cache_control: %v", first)
|
||||
}
|
||||
if second := sys[1].(map[string]any); second["cache_control"] != nil {
|
||||
t.Fatalf("the injection block must carry no cache_control: %v", second)
|
||||
}
|
||||
if msgs, ok := got["messages"].([]any); !ok || len(msgs) != 1 {
|
||||
t.Fatalf("messages = %v", got["messages"])
|
||||
}
|
||||
}
|
||||
|
||||
// TestCapabilityWithoutTheAxisMarshalsUnchanged is the DETERMINISM guard. The resolved Capability
|
||||
// is folded into the job snapshot, so a new field that marshalled for every model would move every
|
||||
// snapshot id and re-buy every book. omitempty is what keeps that from happening; this pins it.
|
||||
func TestCapabilityWithoutTheAxisMarshalsUnchanged(t *testing.T) {
|
||||
data, err := json.Marshal(Capability{Budget: BudgetMaxTokens, Temp: TempSend, MinMaxTokens: 4000}.withDefaults())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(string(data), "SystemMessages") {
|
||||
t.Fatalf("a capability that does not declare the axis must not carry it into the snapshot: %s", data)
|
||||
}
|
||||
want := `{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}`
|
||||
if string(data) != want {
|
||||
t.Fatalf("snapshot bytes drifted:\n got %s\nwant %s", data, want)
|
||||
}
|
||||
// Declaring it DOES move the bytes — that is the point: the flip is a loud --resnapshot.
|
||||
declared, err := json.Marshal(Capability{SystemMessages: SystemMessagesSingle}.withDefaults())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(declared), `"SystemMessages":"single"`) {
|
||||
t.Fatalf("a declared axis must reach the snapshot: %s", declared)
|
||||
}
|
||||
}
|
||||
|
|
@ -296,7 +296,7 @@ func ApplyDecisions(in ApplyInput) ApplyResult {
|
|||
rs := resolveAll(in, &res)
|
||||
refuseSeedConflicts(in, rs, &res)
|
||||
foldAccepted(in, rs, &res)
|
||||
refuseInertDeclines(rs, &res)
|
||||
refuseInertDeclines(in, rs, &res)
|
||||
refuseNewFaults(in, &res)
|
||||
|
||||
if len(res.Rejected) > 0 {
|
||||
|
|
@ -371,7 +371,30 @@ func refuseSeedConflicts(in ApplyInput, rs []resolvedDecision, res *ApplyResult)
|
|||
// exactly that livelock). Refusing there told the owner to «remove it from glossary_seed
|
||||
// instead», which would not have removed the delta row either: the report's own instruction did
|
||||
// not fix what the report complained about.
|
||||
if prior, held := seedSurfaces[text.NormalizeSourceKey(r.key.Src)]; held && !deltaHoldsSurface(in.Delta, r.key.Src) {
|
||||
// ⚠ …AND the refusal's sentence is still true a SECOND time. «Declining it would change
|
||||
// nothing while reading as a decision» is false the moment the reject list ALREADY holds
|
||||
// the surface: the decision is not being read, it is on the record, and what the caller is
|
||||
// doing is re-sending it. Without this clause the refusal fired on the state its own
|
||||
// acceptance produced — the first call was lawful BECAUSE the delta held a row, applying it
|
||||
// DROPPED that row (applyOne → dropTerms), and the identical document was then refused
|
||||
// forever. That broke three promises at once: this file's own idempotency contract
|
||||
// (already_applied, see AcceptedDecision.State) and the caller-facing "re-send the SAME
|
||||
// document, the retry converges". The state needs no crash to arise: a COMPLETED decline
|
||||
// produces exactly it, and the identical document was then bounced forever.
|
||||
//
|
||||
// ⚠ The cost of the narrowing, named rather than discovered later: the clause is keyed on the
|
||||
// SURFACE being on the record, so a decline that is genuinely inert against the signed seed —
|
||||
// and that also happens to be recorded — now answers already_applied instead of repeating the
|
||||
// instruction "Remove it from glossary_seed instead". That instruction is the only one that
|
||||
// works, and it is lost in that corner. Convergence was judged the heavier duty (a caller
|
||||
// following the published retry has no other move), but the trade is real and a report field
|
||||
// carrying the standing fact would close it.
|
||||
//
|
||||
// The refusal it DOES keep is the load-bearing one, and the direction guard below pins it:
|
||||
// a decline of a seed surface with no delta row AND no reject on the record still refuses,
|
||||
// with this same sentence, because there it is true.
|
||||
if prior, held := seedSurfaces[text.NormalizeSourceKey(r.key.Src)]; held &&
|
||||
!deltaHoldsSurface(in.Delta, r.key.Src) && !rejectsHoldSurface(in.Rejects, r.key.Src) {
|
||||
res.Rejected = append(res.Rejected, RejectedDecision{Index: i, Action: r.d.Action, Src: r.key.Src,
|
||||
Reason: fmt.Sprintf("%q is a surface of the SIGNED seed term %q→%q and this book's mined-delta has no row of its own for it: a reject only filters PROPOSALS, so declining it would change nothing while reading as a decision. Remove it from glossary_seed instead", r.key.Src, prior.Src, prior.Dst)})
|
||||
rs[i].ok = false
|
||||
|
|
@ -410,11 +433,21 @@ func foldAccepted(in ApplyInput, rs []resolvedDecision, res *ApplyResult) {
|
|||
// where it did harm: once dropTerms has removed the row nothing owns the alias anyway — unless ANOTHER
|
||||
// term also carries the surface as an alias, which is exactly what the escape let through (delta holding
|
||||
// «Nick→Nik» and «Hero→Nik2 (alias Nick)», decline Nick → accepted, and Hero went on firing Nick).
|
||||
func refuseInertDeclines(rs []resolvedDecision, res *ApplyResult) {
|
||||
// ⚠ AND IT CONVERGES, for the same reason its sibling does: a decline ALREADY on the record is a
|
||||
// decision being re-sent, not one being made. The state is reachable by accepted calls only — decline a
|
||||
// surface that IS a delta term of its own (lawful), then approve a term that carries it as an alias
|
||||
// (lawful) — and after those two the standing ledger holding both decisions was refused forever, taking
|
||||
// the lawful approve down with it, because the layer is all-or-nothing. The check reads in.Rejects and
|
||||
// NOT res.Rejects: the fold has already run by now and its own addReject would otherwise make every
|
||||
// first-time decline look like a repeat, disabling the refusal outright.
|
||||
func refuseInertDeclines(in ApplyInput, rs []resolvedDecision, res *ApplyResult) {
|
||||
for i, r := range rs {
|
||||
if !r.ok || r.d.Action != ActionDecline {
|
||||
continue
|
||||
}
|
||||
if rejectsHoldSurface(in.Rejects, r.key.Src) {
|
||||
continue
|
||||
}
|
||||
if owner, held := aliasOwner(res.Delta, r.key.Src); held {
|
||||
res.Rejected = append(res.Rejected, RejectedDecision{Index: i, Action: r.d.Action, Src: r.key.Src,
|
||||
Reason: fmt.Sprintf("%q is an ALIAS of the approved term %q→%q, not a term of its own: it is already excluded from proposals, and this door cannot remove an alias — decline %q itself to withdraw the whole term", r.key.Src, owner.Src, owner.Dst, owner.Src)})
|
||||
|
|
@ -679,6 +712,20 @@ func deltaHoldsSurface(f seed.File, src string) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
// rejectsHoldSurface reports whether this surface is ALREADY on the reject list — i.e. whether a
|
||||
// decline of it is a decision being re-sent rather than a decision being made. It is the mirror of
|
||||
// deltaHoldsSurface and is normalised the same way addReject normalises, so "already recorded" means
|
||||
// here exactly what it means where the record is written.
|
||||
func rejectsHoldSurface(f seed.RejectFile, src string) bool {
|
||||
nk := text.NormalizeSourceKey(src)
|
||||
for _, r := range f.Rejects {
|
||||
if text.NormalizeSourceKey(strings.TrimSpace(r.Src)) == nk {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func findTerm(terms []seed.Term, key termKey) (seed.Term, int) {
|
||||
for i, t := range terms {
|
||||
if strings.TrimSpace(t.Src) == key.Src && strings.TrimSpace(t.Sense) == key.Sense &&
|
||||
|
|
|
|||
218
backend/internal/membank/decisions_converge_test.go
Normal file
218
backend/internal/membank/decisions_converge_test.go
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
package membank
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"textmachine/backend/internal/seed"
|
||||
"textmachine/backend/internal/store"
|
||||
)
|
||||
|
||||
// decisions_converge_test.go: the door's own idempotency contract, on the ONE trajectory that broke it.
|
||||
//
|
||||
// The contract, in this package's words (AcceptedDecision.State): «a retry of a decision that is
|
||||
// already in the files changes nothing, writes nothing and still exits 0, because a worker that resumes
|
||||
// and retries must not be able to split the state.» The published form of the same promise is
|
||||
// «re-send the SAME document — the retry converges».
|
||||
//
|
||||
// It was false for exactly one document: a DECLINE of a surface that belongs to a signed seed term and
|
||||
// that the mined-delta holds a row for — the lawful livelock repair. The first call is accepted BECAUSE
|
||||
// the delta holds the row; applying it DROPS the row; and refuseSeedConflicts, which fires when the
|
||||
// delta has no row, then refuses the identical document forever. The refusal was reading the state its
|
||||
// own acceptance had produced.
|
||||
|
||||
// seedSurfaceState builds the exact state that trajectory needs: a SIGNED seed term, and a mined-delta
|
||||
// holding a row of its own for the same surface.
|
||||
func seedSurfaceState() ([]store.GlossaryEntry, seed.File) {
|
||||
seedRows := []store.GlossaryEntry{bankRow("方源", "Фан Юань", "approved", "seed")}
|
||||
delta := seed.File{Terms: []seed.Term{{Src: "方源", Dst: "Фан-Юань", Status: "auto"}}}
|
||||
return seedRows, delta
|
||||
}
|
||||
|
||||
func declineOf(src string) Decision { return Decision{Action: ActionDecline, Src: src} }
|
||||
|
||||
// TestTheSameDeclineDocumentConverges is the ordered invariant: send it, apply it, send it again — and
|
||||
// the second answer is «already applied», not a refusal.
|
||||
func TestTheSameDeclineDocumentConverges(t *testing.T) {
|
||||
seedRows, delta := seedSurfaceState()
|
||||
doc := []Decision{declineOf("方源")}
|
||||
|
||||
first := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Decisions: doc})
|
||||
if len(first.Rejected) > 0 {
|
||||
t.Fatalf("the FIRST send is lawful (the delta holds a row of its own): %+v", first.Rejected)
|
||||
}
|
||||
if len(first.Accepted) != 1 || first.Accepted[0].State != StateApplied {
|
||||
t.Fatalf("first send = %+v", first.Accepted)
|
||||
}
|
||||
if !first.DeltaTouched || !first.RejectsTouched {
|
||||
t.Fatalf("a decline over a held surface must touch BOTH documents: %+v", first)
|
||||
}
|
||||
|
||||
// The re-send sees the state the first one produced — which is the whole point.
|
||||
second := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: first.Delta, Rejects: first.Rejects, Decisions: doc})
|
||||
if len(second.Rejected) > 0 {
|
||||
t.Fatalf("the SAME document was refused on re-send — the retry does not converge: %+v", second.Rejected)
|
||||
}
|
||||
if len(second.Accepted) != 1 || second.Accepted[0].State != StateAlreadyApplied {
|
||||
t.Fatalf("the re-send must answer already_applied, got %+v", second.Accepted)
|
||||
}
|
||||
if second.DeltaTouched || second.RejectsTouched {
|
||||
t.Fatalf("a converged re-send must change nothing: delta=%v rejects=%v", second.DeltaTouched, second.RejectsTouched)
|
||||
}
|
||||
// And a THIRD send is the same answer: convergence is a fixed point, not a one-off tolerance.
|
||||
third := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: second.Delta, Rejects: second.Rejects, Decisions: doc})
|
||||
if len(third.Rejected) > 0 || third.Accepted[0].State != StateAlreadyApplied {
|
||||
t.Fatalf("third send = %+v / %+v", third.Accepted, third.Rejected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnAcceptedDeclineDoesNotPoisonTheRestOfItsDocument: the layer is all-or-nothing, so the refusal
|
||||
// did not merely strand the decline — it discarded every lawful decision sent beside it.
|
||||
func TestAnAcceptedDeclineDoesNotPoisonTheRestOfItsDocument(t *testing.T) {
|
||||
seedRows, delta := seedSurfaceState()
|
||||
doc := []Decision{declineOf("方源"), approve("李青", "Ли Цин")}
|
||||
|
||||
first := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Decisions: doc})
|
||||
if len(first.Rejected) > 0 {
|
||||
t.Fatalf("first send: %+v", first.Rejected)
|
||||
}
|
||||
second := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: first.Delta, Rejects: first.Rejects, Decisions: doc})
|
||||
if len(second.Rejected) > 0 {
|
||||
t.Fatalf("the re-send refused, taking the lawful approve down with it: %+v", second.Rejected)
|
||||
}
|
||||
if len(second.Accepted) != 2 {
|
||||
t.Fatalf("both decisions must be answered, got %+v", second.Accepted)
|
||||
}
|
||||
for _, a := range second.Accepted {
|
||||
if a.State != StateAlreadyApplied {
|
||||
t.Fatalf("every decision of a fully-landed document is already_applied, got %+v", a)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheHalfStateOfAnInterruptedDeclineConverges is the case where a refusal was not an inconvenience
|
||||
// but a locked door: the process died between the two renames. Under the rejects-first order the
|
||||
// surviving half is the REJECTS and the delta still holds the row, so the identical document finishes
|
||||
// the job. (The engine's own writeDecisionFiles pins that order; this pins what the order buys.)
|
||||
func TestTheHalfStateOfAnInterruptedDeclineConverges(t *testing.T) {
|
||||
seedRows, delta := seedSurfaceState()
|
||||
doc := []Decision{declineOf("方源")}
|
||||
full := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Decisions: doc})
|
||||
if len(full.Rejected) > 0 {
|
||||
t.Fatalf("setup: %+v", full.Rejected)
|
||||
}
|
||||
|
||||
// Half-state: the REJECTS landed, the delta did not (it still holds the row).
|
||||
half := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: delta, Rejects: full.Rejects, Decisions: doc})
|
||||
if len(half.Rejected) > 0 {
|
||||
t.Fatalf("an interrupted decline must be finishable by re-sending the same document: %+v", half.Rejected)
|
||||
}
|
||||
if !half.DeltaTouched {
|
||||
t.Fatal("the re-send must finish the unwritten half (drop the delta row)")
|
||||
}
|
||||
if deltaHoldsSurface(half.Delta, "方源") {
|
||||
t.Fatal("after the converged re-send the delta must no longer hold the declined surface")
|
||||
}
|
||||
// The finished state is then the fixed point.
|
||||
again := ApplyDecisions(ApplyInput{Seed: seedRows, Delta: half.Delta, Rejects: half.Rejects, Decisions: doc})
|
||||
if len(again.Rejected) > 0 || again.DeltaTouched || again.RejectsTouched {
|
||||
t.Fatalf("after convergence the document must be inert: %+v / %+v", again.Rejected, again.Accepted)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAGenuinelyInertDeclineIsStillRefused is the DIRECTION GUARD, and it is half the order: the
|
||||
// refusal that was narrowed is load-bearing and was bought with a livelock. A decline of a seed surface
|
||||
// with NO delta row of its own AND no reject on the record still refuses — with the same sentence.
|
||||
func TestAGenuinelyInertDeclineIsStillRefused(t *testing.T) {
|
||||
seedRows := []store.GlossaryEntry{bankRow("方源", "Фан Юань", "approved", "seed")}
|
||||
res := ApplyDecisions(ApplyInput{Seed: seedRows, Decisions: []Decision{declineOf("方源")}})
|
||||
if len(res.Rejected) != 1 {
|
||||
t.Fatalf("an inert decline of a signed seed surface must still be refused, got %+v", res.Rejected)
|
||||
}
|
||||
if r := res.Rejected[0].Reason; !strings.Contains(r, "filters PROPOSALS") ||
|
||||
!strings.Contains(r, "Remove it from glossary_seed instead") {
|
||||
t.Fatalf("the refusal must keep its own words, got %q", r)
|
||||
}
|
||||
// The narrowing is EXACTLY «already on the record», nothing wider: an unrelated reject does not
|
||||
// license it.
|
||||
other := seed.RejectFile{Rejects: []seed.Reject{{Src: "李青"}}}
|
||||
res2 := ApplyDecisions(ApplyInput{Seed: seedRows, Rejects: other, Decisions: []Decision{declineOf("方源")}})
|
||||
if len(res2.Rejected) != 1 {
|
||||
t.Fatalf("a reject for a DIFFERENT surface must not lift the refusal, got %+v", res2.Rejected)
|
||||
}
|
||||
}
|
||||
|
||||
// TestARepeatedInertDeclineConverges closes the pair: once the refusal has been lifted for a surface
|
||||
// that IS on the record, the record is what decides — including for a surface the owner declined when
|
||||
// it was still a live delta row and which the seed later grew to cover.
|
||||
func TestARepeatedInertDeclineConverges(t *testing.T) {
|
||||
seedRows := []store.GlossaryEntry{bankRow("方源", "Фан Юань", "approved", "seed")}
|
||||
onRecord := seed.RejectFile{Rejects: []seed.Reject{{Src: "方源", Note: "решено владельцем"}}}
|
||||
res := ApplyDecisions(ApplyInput{Seed: seedRows, Rejects: onRecord, Decisions: []Decision{declineOf("方源")}})
|
||||
if len(res.Rejected) > 0 {
|
||||
t.Fatalf("a decline already on the record must converge, not refuse: %+v", res.Rejected)
|
||||
}
|
||||
if len(res.Accepted) != 1 || res.Accepted[0].State != StateAlreadyApplied {
|
||||
t.Fatalf("got %+v", res.Accepted)
|
||||
}
|
||||
if res.RejectsTouched {
|
||||
t.Fatal("nothing to write: the reject is already there")
|
||||
}
|
||||
// The owner's own words survive the repeat (the note rule of addReject).
|
||||
if res.Rejects.Rejects[0].Note != "решено владельцем" {
|
||||
t.Fatalf("the repeat erased the owner's note: %+v", res.Rejects.Rejects[0])
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheStandingLedgerConvergesThroughBothDoors is the trajectory an adversarial pass walked, and every
|
||||
// step of it is an ACCEPTED call — no crash, no hand edit. A caller who keeps one document as the
|
||||
// standing ledger of their decisions (which is the workflow the published retry describes) re-sends it
|
||||
// and, before the fix, was refused by the OTHER inert-decline door — losing the lawful approve beside it,
|
||||
// because the layer is all-or-nothing.
|
||||
func TestTheStandingLedgerConvergesThroughBothDoors(t *testing.T) {
|
||||
// 方小子 starts as a delta term of its OWN, so declining it is lawful and changes something.
|
||||
delta := seed.File{Terms: []seed.Term{{Src: "方小子", Dst: "Малыш Фан", Status: "approved"}}}
|
||||
// The bank offers 方源 with 方小子 among its aliases, so approving 方源 later makes 方小子 an alias.
|
||||
bank := []store.GlossaryEntry{{Src: "方源", Status: "auto", Source: "mined",
|
||||
Aliases: []store.GlossaryAlias{{Alias: "方小子", AliasType: "mined"}}}}
|
||||
|
||||
step1 := ApplyDecisions(ApplyInput{Bank: bank, Delta: delta, Decisions: []Decision{declineOf("方小子")}})
|
||||
if len(step1.Rejected) > 0 {
|
||||
t.Fatalf("step 1 (decline a term of its own) is lawful: %+v", step1.Rejected)
|
||||
}
|
||||
step2 := ApplyDecisions(ApplyInput{Bank: bank, Delta: step1.Delta, Rejects: step1.Rejects,
|
||||
Decisions: []Decision{{Action: ActionApprove, ID: TermID("方源", "", 0, 0), Dst: "Фан Юань"}}})
|
||||
if len(step2.Rejected) > 0 {
|
||||
t.Fatalf("step 2 (approve the owning term) is lawful: %+v", step2.Rejected)
|
||||
}
|
||||
|
||||
// The standing ledger: BOTH decisions, re-sent over the state they themselves produced.
|
||||
ledger := []Decision{declineOf("方小子"), {Action: ActionApprove, ID: TermID("方源", "", 0, 0), Dst: "Фан Юань"}}
|
||||
res := ApplyDecisions(ApplyInput{Bank: bank, Delta: step2.Delta, Rejects: step2.Rejects, Decisions: ledger})
|
||||
if len(res.Rejected) > 0 {
|
||||
t.Fatalf("the standing ledger was refused on re-send — and an all-or-nothing door discards the lawful approve with it: %+v", res.Rejected)
|
||||
}
|
||||
for _, a := range res.Accepted {
|
||||
if a.State != StateAlreadyApplied {
|
||||
t.Fatalf("every decision of a fully-landed ledger is already_applied, got %+v", a)
|
||||
}
|
||||
}
|
||||
if res.DeltaTouched || res.RejectsTouched {
|
||||
t.Fatal("a converged re-send must change nothing")
|
||||
}
|
||||
}
|
||||
|
||||
// TestDecliningALiveAliasIsStillRefused is the direction guard for the SECOND door: the alias refusal is
|
||||
// load-bearing (declining an alias does not withdraw the term, and the owner has to be told to decline
|
||||
// the term itself), and the narrowing must not have opened it for a surface nobody has decided about.
|
||||
func TestDecliningALiveAliasIsStillRefused(t *testing.T) {
|
||||
delta := seed.File{Terms: []seed.Term{{Src: "方源", Dst: "Фан Юань", Status: "approved",
|
||||
Aliases: []seed.Alias{{Alias: "方小子"}}}}}
|
||||
res := ApplyDecisions(ApplyInput{Delta: delta, Decisions: []Decision{declineOf("方小子")}})
|
||||
if len(res.Rejected) != 1 {
|
||||
t.Fatalf("declining a live alias must still be refused, got %+v", res.Rejected)
|
||||
}
|
||||
if r := res.Rejected[0].Reason; !strings.Contains(r, "is an ALIAS of the approved term") {
|
||||
t.Fatalf("the refusal must keep its own words, got %q", r)
|
||||
}
|
||||
}
|
||||
|
|
@ -185,6 +185,12 @@ func FuzzDecisionDocument(f *testing.F) {
|
|||
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"approve","src":"赵大","dst":"Другое"}]}`)
|
||||
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"approve","src":"x","sense":"s","since_chapter":-1,"dst":"y"}]}`)
|
||||
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[]}`)
|
||||
// The lawful decline of a SEED SURFACE the delta holds a row for — the livelock repair, and the one
|
||||
// trajectory whose re-send the door used to refuse forever. Oracle 4 below is exactly the property
|
||||
// it breaks; the corpus simply could not reach the state, because the delta fixture held no row for
|
||||
// a seed surface. One row (老赵, an alias of the signed 赵大) and this seed put it in reach.
|
||||
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"decline","src":"老赵"}]}`)
|
||||
f.Add(`{"decisions_version":"tm-bank-decisions-v1","book_id":"b","decisions":[{"action":"decline","src":"老赵"},{"action":"approve","src":"李青","dst":"Ли Цин"}]}`)
|
||||
f.Add(`not json at all`)
|
||||
|
||||
bank := []store.GlossaryEntry{
|
||||
|
|
@ -212,6 +218,11 @@ func FuzzDecisionDocument(f *testing.F) {
|
|||
Voices: []store.VoiceProfile{{Src: "赵大", SinceCh: 1}},
|
||||
Delta: seed.File{Terms: []seed.Term{
|
||||
{Src: "花月", Dst: "Хуа Юэ", Status: "approved", Aliases: []seed.Alias{{Alias: "月妹", Type: "mined"}}},
|
||||
// A row whose OWN src is a surface of the SIGNED seed term above (赵大's alias 老赵). It is
|
||||
// the collision the seed-conflict carve-out exists to let the owner repair, and without it
|
||||
// in the fixture the corpus cannot reach the state whose re-send used to be refused —
|
||||
// oracle 4 owned the property and never got to exercise it.
|
||||
{Src: "老赵", Dst: "Старина Чжао", Status: "approved"},
|
||||
}},
|
||||
Rejects: seed.RejectFile{Rejects: []seed.Reject{{Src: "水", Note: "n"}}},
|
||||
Decisions: doc.Decisions,
|
||||
|
|
|
|||
|
|
@ -432,10 +432,19 @@ func changedFiles(st bookState, res membank.ApplyResult) (delta, rejects bool) {
|
|||
// What this is NOT: atomicity of the PAIR against a dying process. The window between the two renames
|
||||
// remains, and closing it needs a journal across two files — a mechanism this door has not earned. The
|
||||
// residue is covered by convergence: the call is all-or-nothing and recomputed from scratch, so a retry
|
||||
// of the same document converges from either half-state, every half-state leaves the term UNDECIDED
|
||||
// rather than wrongly decided, and a killed process prints no report. The RENAME ORDER is arbitrary and
|
||||
// says so — delta-first is safe for an approve and not for a decline, the mirror order swaps which verb
|
||||
// loses, and convergence is what actually carries both.
|
||||
// of the same document converges from either half-state, and a killed process prints no report.
|
||||
// ⚠ What a half-state leaves is stated precisely, because the older wording ("every half-state leaves
|
||||
// the term UNDECIDED rather than wrongly decided") stopped being true when the order changed: under
|
||||
// rejects-first an interrupted DECLINE leaves the decision RECORDED while the delta row is still on
|
||||
// disk. That is not "wrongly decided" — it is decided correctly and half-written — and it is only safe
|
||||
// because the bank loader drops a delta row whose surface is on the reject list (mining.go
|
||||
// loadMinedDelta). Without that filter this order would have bought recoverability with a window in
|
||||
// which a paid run injects a term the owner declined. The RENAME ORDER is what makes
|
||||
// that true, and it is REJECTS FIRST. It used to be delta-first with a comment calling the order
|
||||
// arbitrary — "the mirror order swaps which verb loses, and convergence is what actually carries both".
|
||||
// The second half of that sentence was the load-bearing one and it was false for a decline: dropping the
|
||||
// delta row is exactly what makes refuseSeedConflicts fire on the re-send, so delta-first did not swap
|
||||
// which verb loses, it chose the verb that CANNOT recover. The order below is argued in place.
|
||||
//
|
||||
// The per-file return is the report's: which file this call replaced on disk, however far it got.
|
||||
func writeDecisionFiles(book *config.Book, res membank.ApplyResult, deltaChanged, rejectsChanged bool) (writtenFiles, error) {
|
||||
|
|
@ -455,20 +464,35 @@ func writeDecisionFiles(book *config.Book, res membank.ApplyResult, deltaChanged
|
|||
return wrote, err
|
||||
}
|
||||
}
|
||||
if deltaStage != nil {
|
||||
if err := deltaStage.commit(); err != nil {
|
||||
if rejectsStage != nil {
|
||||
rejectsStage.abort()
|
||||
// REJECTS FIRST, and the order is NOT arbitrary — see the header. Whichever document lands
|
||||
// alone, the re-send has to converge, and only this order lets it for BOTH verbs:
|
||||
//
|
||||
// rejects-first, decline killed between the renames → the reject is recorded and the delta
|
||||
// STILL HOLDS the row, so refuseSeedConflicts stays silent (its own precondition holds) and
|
||||
// the re-send drops the row and no-ops the reject: converged.
|
||||
// rejects-first, approve killed between → the reject is withdrawn and the delta lacks the row,
|
||||
// so the re-send merges the row and no-ops the withdrawal: converged.
|
||||
// delta-first, decline killed between → the row is GONE and no reject was recorded, and the two
|
||||
// files are then byte-for-byte the state in which declining that surface is genuinely inert.
|
||||
// No predicate over them can tell the half-state from the inert one, so the re-send is refused
|
||||
// and the term is stranded neither approved nor declined, with no channel forward.
|
||||
//
|
||||
// That last line is why this is a swap and not a preference: the delta is the document the
|
||||
// seed-conflict refusal READS, so the document that decides must not be the one that lands first.
|
||||
if rejectsStage != nil {
|
||||
if err := rejectsStage.commit(); err != nil {
|
||||
if deltaStage != nil {
|
||||
deltaStage.abort()
|
||||
}
|
||||
return wrote, err
|
||||
}
|
||||
wrote.delta = true
|
||||
wrote.rejects = true
|
||||
}
|
||||
if rejectsStage != nil {
|
||||
if err := rejectsStage.commit(); err != nil {
|
||||
if deltaStage != nil {
|
||||
if err := deltaStage.commit(); err != nil {
|
||||
return wrote, err
|
||||
}
|
||||
wrote.rejects = true
|
||||
wrote.delta = true
|
||||
}
|
||||
// The decision files are the USER's words, so their durability is not left to the kernel's leisure:
|
||||
// the renames' directory entries are flushed here (both files live in the book's directory — the
|
||||
|
|
|
|||
185
backend/internal/pipeline/bankdecisions_converge_test.go
Normal file
185
backend/internal/pipeline/bankdecisions_converge_test.go
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
package pipeline
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"textmachine/backend/internal/config"
|
||||
"textmachine/backend/internal/membank"
|
||||
)
|
||||
|
||||
// bankdecisions_converge_test.go: the same convergence contract as membank/decisions_converge_test.go,
|
||||
// but through the DOOR — real files, the real lock, the real refusal classes. The semantics are pinned
|
||||
// one layer down; what is pinned here is that a caller who follows the published instruction («re-send
|
||||
// the SAME document») is not answered with a refusal class and left with nowhere to go.
|
||||
|
||||
// declineProject writes a book that carries a SIGNED seed term plus a mined-delta holding a row of its
|
||||
// own for the same surface — the lawful livelock repair, and the one state the door could not converge on.
|
||||
func declineProject(t *testing.T) string {
|
||||
t.Helper()
|
||||
cfg := decideProject(t)
|
||||
dir := filepath.Dir(cfg)
|
||||
writeFile(t, filepath.Join(dir, "glossary-seed.yaml"), `
|
||||
terms:
|
||||
- src: 方源
|
||||
dst: Фан Юань
|
||||
type: name
|
||||
status: approved
|
||||
`)
|
||||
// The book must DECLARE the seed; the delta/rejects paths stay conventional.
|
||||
raw, err := os.ReadFile(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, cfg, string(raw)+"\nglossary_seed: glossary-seed.yaml\n")
|
||||
writeFile(t, filepath.Join(dir, "decide-book"+config.MinedDeltaSuffix), `
|
||||
terms:
|
||||
- src: 方源
|
||||
dst: Фан-Юань
|
||||
status: auto
|
||||
`)
|
||||
return cfg
|
||||
}
|
||||
|
||||
func declineDoc(t *testing.T, dir string) string {
|
||||
t.Helper()
|
||||
return decisionsDoc(t, dir, membank.Decision{Action: membank.ActionDecline, Src: "方源"})
|
||||
}
|
||||
|
||||
// TestReSendingAnAppliedDeclineIsNotARefusal is the door-level form of the ordered invariant.
|
||||
func TestReSendingAnAppliedDeclineIsNotARefusal(t *testing.T) {
|
||||
cfg := declineProject(t)
|
||||
dir := filepath.Dir(cfg)
|
||||
doc := declineDoc(t, dir)
|
||||
|
||||
first, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("the first send is lawful: %v", err)
|
||||
}
|
||||
if !first.Changed || len(first.Accepted) != 1 || first.Accepted[0].State != membank.StateApplied {
|
||||
t.Fatalf("first send = %+v", first)
|
||||
}
|
||||
|
||||
second, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("re-sending the SAME document must converge, got %v (class %v)", err, refusalClassOf(err))
|
||||
}
|
||||
if second.Changed {
|
||||
t.Fatalf("a converged re-send changes nothing: %+v", second)
|
||||
}
|
||||
if len(second.Accepted) != 1 || second.Accepted[0].State != membank.StateAlreadyApplied {
|
||||
t.Fatalf("re-send = %+v", second.Accepted)
|
||||
}
|
||||
// The published instruction includes a SAFE PREVIEW; it must answer the same way, not a refusal.
|
||||
dry, err := ApplyBankDecisions(t.Context(), cfg, doc, true)
|
||||
if err != nil {
|
||||
t.Fatalf("--dry-run of an applied document must not refuse: %v", err)
|
||||
}
|
||||
if dry.Changed {
|
||||
t.Fatalf("dry-run of an applied document projects no change: %+v", dry)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnInterruptedDeclineIsFinishedByTheSameDocument is the case that locked a user out: the process
|
||||
// died between the two renames. The rejects-first order leaves the DELTA row intact, so the identical
|
||||
// document finishes the job instead of being bounced.
|
||||
func TestAnInterruptedDeclineIsFinishedByTheSameDocument(t *testing.T) {
|
||||
cfg := declineProject(t)
|
||||
dir := filepath.Dir(cfg)
|
||||
doc := declineDoc(t, dir)
|
||||
|
||||
// Reproduce the half-state exactly as the interrupted write leaves it: the reject list landed, the
|
||||
// delta did not. (writeDecisionFiles commits rejects first — see TestTheRenameOrderIsWhatMakesADeclineRecover.)
|
||||
writeFile(t, filepath.Join(dir, "decide-book"+configMinedRejectsSuffix), "rejects:\n - src: 方源\n")
|
||||
|
||||
rep, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
|
||||
if err != nil {
|
||||
t.Fatalf("the half-state must be finishable by the same document, got %v (class %v)", err, refusalClassOf(err))
|
||||
}
|
||||
if !rep.Changed || !rep.WrittenDelta {
|
||||
t.Fatalf("the re-send must write the half that did not land: %+v", rep)
|
||||
}
|
||||
raw := readAll(t, filepath.Join(dir, "decide-book"+configMinedDeltaSuffix))
|
||||
if strings.Contains(raw, "方源") {
|
||||
t.Fatalf("the declined surface must be gone from the delta after the converged re-send:\n%s", raw)
|
||||
}
|
||||
// And now it is inert.
|
||||
again, err := ApplyBankDecisions(t.Context(), cfg, doc, false)
|
||||
if err != nil || again.Changed {
|
||||
t.Fatalf("after convergence the document is a no-op: %+v / %v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestAnInertDeclineIsStillRefusedAtTheDoor is the direction guard at the door: the narrowing must not
|
||||
// have opened the refusal the livelock bought.
|
||||
func TestAnInertDeclineIsStillRefusedAtTheDoor(t *testing.T) {
|
||||
cfg := decideProject(t) // no mined-delta at all
|
||||
dir := filepath.Dir(cfg)
|
||||
writeFile(t, filepath.Join(dir, "glossary-seed.yaml"), `
|
||||
terms:
|
||||
- src: 方源
|
||||
dst: Фан Юань
|
||||
type: name
|
||||
status: approved
|
||||
`)
|
||||
raw, err := os.ReadFile(cfg)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeFile(t, cfg, string(raw)+"\nglossary_seed: glossary-seed.yaml\n")
|
||||
|
||||
rep, err := ApplyBankDecisions(t.Context(), cfg, declineDoc(t, dir), false)
|
||||
if refusalClassOf(err) != RefusalDecisionsRejected {
|
||||
t.Fatalf("a genuinely inert decline must still be refused, got %v", err)
|
||||
}
|
||||
if len(rep.Rejected) != 1 || !strings.Contains(rep.Rejected[0].Reason, "filters PROPOSALS") {
|
||||
t.Fatalf("the refusal must keep its own words: %+v", rep.Rejected)
|
||||
}
|
||||
// Nothing was written by the refusal.
|
||||
if _, serr := os.Stat(filepath.Join(dir, "decide-book"+configMinedRejectsSuffix)); !errors.Is(serr, os.ErrNotExist) {
|
||||
t.Fatalf("a refused call must write nothing: %v", serr)
|
||||
}
|
||||
}
|
||||
|
||||
// TestADeclinedSurfaceNeverEntersTheBankFromTheDelta is the other half of the rejects-first order, and
|
||||
// it exists because an adversarial pass showed the order alone was not safe. Rejects-first is what lets
|
||||
// an interrupted decline converge — and it does so by leaving the DELTA ROW on disk. seedGlossary loads
|
||||
// the mined-delta into the live bank and never consulted the reject list, so between the crash and the
|
||||
// operator's re-send a PAID run would have injected into the editor's glossary a term the owner had
|
||||
// explicitly declined. Convergence must not be bought with a second silent harm.
|
||||
func TestADeclinedSurfaceNeverEntersTheBankFromTheDelta(t *testing.T) {
|
||||
bookPath := setupProjectOpts(t, "http://127.0.0.1:1", projectOpts{})
|
||||
r := newRunner(t, bookPath)
|
||||
defer r.Close()
|
||||
|
||||
// The exact half-state the rejects-first order produces: the reject landed, the delta row did not go.
|
||||
writeFile(t, r.Book.MinedDelta, "terms:\n - src: 方源\n dst: Фан-Юань\n status: approved\n")
|
||||
writeFile(t, r.Book.MinedRejects, "rejects:\n - src: 方源\n")
|
||||
|
||||
got, err := r.loadMinedDelta()
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
for _, e := range got {
|
||||
if e.Src == "方源" {
|
||||
t.Fatalf("a DECLINED surface reached the bank through the mined-delta: %+v", e)
|
||||
}
|
||||
}
|
||||
if len(got) != 0 {
|
||||
t.Fatalf("the delta held only the declined term; nothing may survive it: %+v", got)
|
||||
}
|
||||
|
||||
// The filter is scoped to what the owner actually declined — an undeclined row still loads.
|
||||
writeFile(t, r.Book.MinedDelta,
|
||||
"terms:\n - src: 方源\n dst: Фан-Юань\n status: approved\n - src: 李青\n dst: Ли Цин\n status: approved\n")
|
||||
got2, err := r.loadMinedDelta()
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(got2) != 1 || got2[0].Src != "李青" {
|
||||
t.Fatalf("only the declined surface may be dropped, got %+v", got2)
|
||||
}
|
||||
}
|
||||
|
|
@ -140,8 +140,17 @@ func TestNothingLandedIsTheWriteIncompleteClass(t *testing.T) {
|
|||
}
|
||||
|
||||
// TestHalfLandedIsNamedPerFile drives the write phase directly into its between-the-renames failure —
|
||||
// the delta commits, the rejects rename hits an existing directory — and pins that the per-file truth
|
||||
// and the single class survive it. One class for both halves; the report carries the difference.
|
||||
// the FIRST document commits, the SECOND rename hits an existing directory — and pins that the per-file
|
||||
// truth and the single class survive it. One class for both halves; the report carries the difference.
|
||||
//
|
||||
// ⚠ THE FIXTURE'S TARGET MOVED, THE PROPERTY DID NOT. This test used to occupy the REJECTS path,
|
||||
// because the commit order was delta-first. That order is now rejects-first and the reason is argued in
|
||||
// writeDecisionFiles: delta-first stranded a decline forever (dropping the delta row is exactly what
|
||||
// makes refuseSeedConflicts fire on the re-send, and no predicate over the two files can tell that
|
||||
// half-state from a genuinely inert decline). So the surviving half is now the rejects and the failing
|
||||
// rename is the delta — the same between-the-renames failure, seen from the other side. What is asserted
|
||||
// is unchanged and unweakened: exactly one half landed, it is the PROVEN bytes, and the report names
|
||||
// which. The order itself is pinned, with its reason, by TestTheRenameOrderIsWhatMakesADeclineRecover.
|
||||
func TestHalfLandedIsNamedPerFile(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
book := &config.Book{BookID: "half-book",
|
||||
|
|
@ -149,27 +158,52 @@ func TestHalfLandedIsNamedPerFile(t *testing.T) {
|
|||
MinedRejects: filepath.Join(dir, "half-book"+config.MinedRejectsSuffix),
|
||||
ProjectDB: filepath.Join(dir, "half-book.db"),
|
||||
}
|
||||
// The rejects target IS an existing non-empty directory: staging beside it succeeds, the rename fails.
|
||||
if err := os.MkdirAll(filepath.Join(book.MinedRejects, "occupied"), 0o755); err != nil {
|
||||
// The DELTA target IS an existing non-empty directory: staging beside it succeeds, the rename fails.
|
||||
if err := os.MkdirAll(filepath.Join(book.MinedDelta, "occupied"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := membank.ApplyResult{DeltaBytes: []byte("terms: []\n"), RejectBytes: []byte("rejects: []\n")}
|
||||
wrote, err := writeDecisionFiles(book, res, true, true)
|
||||
if err == nil {
|
||||
t.Fatal("the rejects rename cannot succeed onto a directory")
|
||||
t.Fatal("the delta rename cannot succeed onto a directory")
|
||||
}
|
||||
if !wrote.delta || wrote.rejects {
|
||||
t.Fatalf("per-file truth: delta landed, rejects did not — got %+v", wrote)
|
||||
if wrote.delta || !wrote.rejects {
|
||||
t.Fatalf("per-file truth: rejects landed, delta did not — got %+v", wrote)
|
||||
}
|
||||
if raw, rerr := os.ReadFile(book.MinedDelta); rerr != nil || string(raw) != "terms: []\n" {
|
||||
if raw, rerr := os.ReadFile(book.MinedRejects); rerr != nil || string(raw) != "rejects: []\n" {
|
||||
t.Fatalf("the landed half must be the proven bytes: %q / %v", raw, rerr)
|
||||
}
|
||||
rep := finishReport(decisionVerdict(book, membank.ApplyResult{}), outcomeWriteFailed, book, bookState{}, membank.ApplyResult{}, wrote)
|
||||
if rep.Mode != "write_incomplete" || !rep.WrittenDelta || rep.WrittenRejects || !rep.Changed || len(rep.Accepted) != 0 {
|
||||
if rep.Mode != "write_incomplete" || rep.WrittenDelta || !rep.WrittenRejects || !rep.Changed || len(rep.Accepted) != 0 {
|
||||
t.Fatalf("half landed and the report must say which half: %+v", rep)
|
||||
}
|
||||
}
|
||||
|
||||
// TestTheRenameOrderIsWhatMakesADeclineRecover pins the ORDER itself, and its REASON, so it cannot be
|
||||
// flipped back as a tidy-up. The comment it replaces called the order arbitrary; it is not.
|
||||
func TestTheRenameOrderIsWhatMakesADeclineRecover(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
book := &config.Book{BookID: "order-book",
|
||||
MinedDelta: filepath.Join(dir, "order-book"+config.MinedDeltaSuffix),
|
||||
MinedRejects: filepath.Join(dir, "order-book"+config.MinedRejectsSuffix),
|
||||
}
|
||||
// Make the SECOND rename fail, whichever it is, by occupying the delta path.
|
||||
if err := os.MkdirAll(filepath.Join(book.MinedDelta, "occupied"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
res := membank.ApplyResult{DeltaBytes: []byte("terms: []\n"), RejectBytes: []byte("rejects: []\n")}
|
||||
wrote, _ := writeDecisionFiles(book, res, true, true)
|
||||
// The DECIDING document — the one refuseSeedConflicts reads — must be the one still unwritten, so
|
||||
// the interrupted decline's precondition survives and the re-send converges. If the delta had landed
|
||||
// first, the row would be gone, the reject absent, and the identical document refused forever.
|
||||
if wrote.delta {
|
||||
t.Fatal("the delta must NOT be the first document to land: a decline killed after it is unrecoverable")
|
||||
}
|
||||
if !wrote.rejects {
|
||||
t.Fatal("the rejects must land first, so an interrupted decline leaves the delta row intact for the re-send")
|
||||
}
|
||||
}
|
||||
|
||||
// TestBothDocumentsAreStagedBeforeEitherRename pins the §4.8 order itself: a failure to STAGE the
|
||||
// second document must leave the FIRST unmoved — under the old interleaved write the delta was already
|
||||
// committed by then, which is how «half landed» happened for a failure that staging would have caught
|
||||
|
|
|
|||
|
|
@ -67,6 +67,19 @@ type ChunkOutcome struct {
|
|||
Disposition Disposition // ok | flagged (a chunk has no "skipped" — that is a per-later-stage state)
|
||||
FlagReason FlagReason // the flagging stage's reason ("" when ok)
|
||||
CostUSD float64 // THIS run's spend on this chunk
|
||||
// DroppedMembers counts the unit's member chunks whose draft flagged and which the c-lite editor
|
||||
// therefore left OUT of the edit (waverun.go runEditUnit). It is what makes "the shipped text is
|
||||
// INCOMPLETE" a fact the renderers can state instead of infer: FlagReason cannot carry it, because
|
||||
// a unit can be flagged for the EDIT's own reason (a cosmetic sanitizer strip) while a member was
|
||||
// dropped as well, and then the reason names the strip and says nothing about the missing member.
|
||||
// 0 for every unit that lost nothing.
|
||||
DroppedMembers int
|
||||
// DroppedReason is why the FIRST dropped member's draft flagged — the cause of the HOLE, which is
|
||||
// not the same fact as FlagReason. When the edit ALSO flags on its own account (a cosmetic
|
||||
// sanitizer strip), FlagReason is the strip's and says nothing about the missing text; printing it
|
||||
// as the cause of the loss tells the reader a clean-up ate a chunk of the book, which is the very
|
||||
// false claim this pack exists to remove. "" when nothing dropped.
|
||||
DroppedReason FlagReason
|
||||
}
|
||||
|
||||
// BookResult aggregates a whole run over every chapter×chunk.
|
||||
|
|
|
|||
|
|
@ -42,6 +42,16 @@ type ChunkExport struct {
|
|||
// (千万) source-vs-target checkers' false-positive rate. Omitted by default (the export contract is
|
||||
// target-only); the source is the $0 manifest re-chunk, never a stored/billed artifact.
|
||||
Source string `json:"source,omitempty"`
|
||||
// DroppedMembers counts the member chunks the c-lite editor left OUT of this unit's edit, so a
|
||||
// consumer can say "the text below is INCOMPLETE" without inferring it from FlagReason — which
|
||||
// cannot carry it: an edit flagged for its OWN reason (a cosmetic sanitizer strip) keeps that
|
||||
// reason even when a member also dropped, and the reason then describes the strip and is silent
|
||||
// about the missing text. Additive and omitempty: a unit that lost nothing serializes exactly as
|
||||
// before this field existed.
|
||||
DroppedMembers int `json:"dropped_members,omitempty"`
|
||||
// DroppedReason is why the FIRST dropped member flagged — the cause of the HOLE, which FlagReason
|
||||
// is not when the edit flagged on its own account as well. "" when nothing dropped.
|
||||
DroppedReason string `json:"dropped_reason,omitempty"`
|
||||
}
|
||||
|
||||
// exportVersion versions the SHAPE of the export document.
|
||||
|
|
@ -222,6 +232,25 @@ func (r *Runner) exportUnit(u editUnit, cs store.ChunkStatus, byChunk map[chunkK
|
|||
// the FIRST dropped member's reason AND detail, so it is self-consistent rather than the ok edit's blank
|
||||
// detail, and ce.FinalText (the edited clean remainder) still ships.
|
||||
drops := memberDrops(u, byChunk, draftStageNames)
|
||||
// ⚠ A DRAFT-ONLY pipeline has no members to drop: the unit IS one draft chunk and that chunk's own
|
||||
// draft row is its FINAL row, so memberDrops — which looks for a flagged DRAFT row among the unit's
|
||||
// members — reports the unit's own flag as a lost member. Reading that as a hole would announce
|
||||
// «a fragment is missing» over a chunk whose text is entirely present (a cosmetically stripped one
|
||||
// ships all of its prose), which is the anti-scope this pack is explicitly bound by: a marker on
|
||||
// complete text misinforms. Before the DroppedMembers field the mistake was impossible by accident
|
||||
// — droppedAny also demanded an ok edit — so the guard has to be stated now that the count is kept
|
||||
// on its own.
|
||||
if r.finalStageWave() == waveEdit {
|
||||
// Recorded for EVERY drop, not only for the one that overrides an ok edit: the unit whose edit
|
||||
// flagged on its own account lost the member's text just the same, and that is the case the
|
||||
// reason-only reading was blind to.
|
||||
ce.DroppedMembers = len(drops)
|
||||
if len(drops) > 0 {
|
||||
ce.DroppedReason = drops[0].Reason
|
||||
}
|
||||
} else {
|
||||
drops = nil
|
||||
}
|
||||
droppedAny := len(drops) > 0 && ce.Disposition == string(DispOK)
|
||||
if droppedAny {
|
||||
ce.Disposition = string(DispFlagged)
|
||||
|
|
|
|||
|
|
@ -850,6 +850,40 @@ func (r *Runner) loadMinedDelta() ([]store.GlossaryEntry, error) {
|
|||
if err != nil {
|
||||
return nil, fmt.Errorf("pipeline: load mined-delta %s: %w", r.Book.MinedDelta, err)
|
||||
}
|
||||
// A DECLINED surface never enters the bank, even from the delta. The two decision documents are ONE
|
||||
// state, and a surface on the reject list is decided-against; the delta is the only door through
|
||||
// which a decided-against surface could still reach a paid run, so it is closed here.
|
||||
//
|
||||
// In ordinary operation the two documents cannot both hold a surface — applying a decline drops the
|
||||
// delta row and records the reject in ONE act. The state exists anyway, by two routes, and both end
|
||||
// in the editor's glossary if this filter is absent: a hand-edited delta, and the half-state a
|
||||
// process killed between the two renames leaves behind. The second is why this sits beside the
|
||||
// rejects-first rename order (writeDecisionFiles): that order is what lets an interrupted decline's
|
||||
// re-send converge, and it does so by leaving the delta row on disk — so without this filter the
|
||||
// convergence would have been bought with a window in which a paid run injects a term the owner
|
||||
// explicitly declined. Loud, because a delta row and a reject for one surface means the two
|
||||
// documents disagree and somebody should look.
|
||||
rejects, rerr := r.loadMinedRejects()
|
||||
if rerr != nil {
|
||||
return nil, rerr
|
||||
}
|
||||
if len(rejects) > 0 {
|
||||
kept := entries[:0:0]
|
||||
var declined []string
|
||||
for _, e := range entries {
|
||||
if rejects[text.NormalizeSourceKey(e.Src)] {
|
||||
declined = append(declined, e.Src)
|
||||
continue
|
||||
}
|
||||
kept = append(kept, e)
|
||||
}
|
||||
if len(declined) > 0 {
|
||||
r.Log.Warn("mined-delta holds term(s) the owner has DECLINED; they are NOT entering the bank (the two decision documents disagree — a hand edit, or a write interrupted between the two files)",
|
||||
"book", r.Book.BookID, "declined", strings.Join(declined, ", "),
|
||||
"mined_delta", r.Book.MinedDelta, "mined_rejects", r.Book.MinedRejects)
|
||||
}
|
||||
entries = kept
|
||||
}
|
||||
for i := range entries {
|
||||
entries[i].Source = "mined" // override the seed loader's Source:seed → mined (base-excluded)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -123,6 +123,10 @@ type projectOpts struct {
|
|||
// draftOnly drops the editor stage, so the DRAFT is the shipping output (the pipeline shape the wave
|
||||
// executor calls draft-only). false keeps the historical two-stage fixture byte-identical.
|
||||
draftOnly bool
|
||||
// systemMessagesSingle declares the fake PROVIDER as an endpoint that carries exactly ONE system
|
||||
// message (the Gemini OpenAI-compat quirk). false omits the capabilities block entirely, so every
|
||||
// pre-existing fixture's models.yaml stays byte-identical.
|
||||
systemMessagesSingle bool
|
||||
}
|
||||
|
||||
func setupProjectOpts(t *testing.T, providerURL string, o projectOpts) string {
|
||||
|
|
@ -146,6 +150,10 @@ func setupProjectOpts(t *testing.T, providerURL string, o projectOpts) string {
|
|||
writeFile(t, filepath.Join(dir, "prompts", "editor.md"),
|
||||
"Редактируй перевод.\n---USER---\nИсходник: {{text}}\nЧерновик перевода для редактуры: {{draft}}")
|
||||
|
||||
providerCaps := ""
|
||||
if o.systemMessagesSingle {
|
||||
providerCaps = " capabilities: { system_messages: single }\n"
|
||||
}
|
||||
writeFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
|
||||
prices_checked: %q
|
||||
default_model: fake-model
|
||||
|
|
@ -153,12 +161,12 @@ providers:
|
|||
fake:
|
||||
kind: openai
|
||||
base_url: %q
|
||||
timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 }
|
||||
%s 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))
|
||||
`, time.Now().UTC().Format("2006-01-02"), providerURL, providerCaps))
|
||||
|
||||
gatesBlock := o.gatesYAML
|
||||
if o.postcheckGate {
|
||||
|
|
|
|||
138
backend/internal/pipeline/systemmessages_wave_test.go
Normal file
138
backend/internal/pipeline/systemmessages_wave_test.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
package pipeline
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// systemmessages_wave_test.go proves the SystemMessages fix on the two paths that actually carry
|
||||
// the memory bank to a model — the DRAFT wave (the translator's src→dst glossary) and the EDIT
|
||||
// wave (the editor's CONFIRMED-dst constraint block). The edit wave matters on its own account:
|
||||
// it is the wave that delivers the OWNER'S SIGNED decisions, so a hop that ate its system message
|
||||
// would silently eat the owner's edits, not merely a machine's proposals.
|
||||
//
|
||||
// ⚠ The assertion is the POST-FIX invariant, deliberately: "the injection is in the request" is
|
||||
// true of unfixed code too (the assembler always built it; the endpoint dropped it), so it would
|
||||
// be green on a broken engine. What separates fixed from unfixed is ONE system message with the
|
||||
// injection INSIDE it.
|
||||
|
||||
// systemMessagesOf returns the wire body's system messages, in order.
|
||||
func systemMessagesOf(t *testing.T, body string) []wireMsg {
|
||||
t.Helper()
|
||||
var out []wireMsg
|
||||
for _, m := range bodyMessages(t, body) {
|
||||
if m.Role == "system" {
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TestSingleSystemEndpointCarriesTheBankOnBothWaves runs a real two-wave book against a provider
|
||||
// declared single-system and asserts, for the draft AND the edit request, that the endpoint got
|
||||
// exactly one system message and that the bank block is inside it.
|
||||
func TestSingleSystemEndpointCarriesTheBankOnBothWaves(t *testing.T) {
|
||||
rec := &reqRec{}
|
||||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||||
if isEditBody(body) {
|
||||
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД про Судзуки.", "stop"
|
||||
}
|
||||
return "Судзуки пошёл в тихую библиотеку.", "stop"
|
||||
})
|
||||
defer srv.Close()
|
||||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
|
||||
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed, systemMessagesSingle: true,
|
||||
})
|
||||
|
||||
r := newRunner(t, bookPath)
|
||||
res, err := r.TranslateBook(context.Background())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if res.Flagged != 0 {
|
||||
t.Fatalf("the join must not disturb the verdict: flagged=%d", res.Flagged)
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
wave string
|
||||
isEdit bool
|
||||
wantBlock string // the bank text this wave's role receives
|
||||
}{
|
||||
// waverun.go runDraftChunk: the translator's src→dst glossary.
|
||||
{wave: "draft", isEdit: false, wantBlock: "ГЛОССАРИЙ"},
|
||||
// waverun.go runEditUnit: the editor's CONFIRMED-dst constraint block — the carrier of the
|
||||
// owner's signed decisions.
|
||||
{wave: "edit", isEdit: true, wantBlock: "КАНОНИЧЕСКИЕ ПЕРЕВОДЫ"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
t.Run(c.wave, func(t *testing.T) {
|
||||
seen := 0
|
||||
for _, body := range rec.all() {
|
||||
if isEditBody(body) != c.isEdit {
|
||||
continue
|
||||
}
|
||||
seen++
|
||||
sys := systemMessagesOf(t, body)
|
||||
if len(sys) != 1 {
|
||||
t.Fatalf("%s wave: a single-system endpoint received %d system messages — the bank is being dropped by the provider",
|
||||
c.wave, len(sys))
|
||||
}
|
||||
if !strings.Contains(sys[0].Content, c.wantBlock) {
|
||||
t.Fatalf("%s wave: the bank block %q is not inside the single system message: %q",
|
||||
c.wave, c.wantBlock, sys[0].Content)
|
||||
}
|
||||
if !strings.Contains(sys[0].Content, "Судзуки") {
|
||||
t.Fatalf("%s wave: the approved rendering did not reach the model: %q", c.wave, sys[0].Content)
|
||||
}
|
||||
// The stable prompt prefix must stay FIRST inside the join, or the provider's prefix
|
||||
// cache stops hitting on every call.
|
||||
iPrefix := strings.Index(sys[0].Content, "Редактируй перевод.")
|
||||
if !c.isEdit {
|
||||
iPrefix = strings.Index(sys[0].Content, "Переводи с")
|
||||
}
|
||||
if iPrefix != 0 {
|
||||
t.Fatalf("%s wave: the stable prefix is not first in the join (index %d): %q", c.wave, iPrefix, sys[0].Content)
|
||||
}
|
||||
// The user turn survives the join untouched, and the bank never leaks into it.
|
||||
msgs := bodyMessages(t, body)
|
||||
if len(msgs) != 2 || msgs[1].Role != "user" {
|
||||
t.Fatalf("%s wave: wire shape = %+v, want [system user]", c.wave, msgs)
|
||||
}
|
||||
if strings.Contains(msgs[1].Content, c.wantBlock) {
|
||||
t.Fatalf("%s wave: the bank leaked into the user message: %q", c.wave, msgs[1].Content)
|
||||
}
|
||||
}
|
||||
if seen == 0 {
|
||||
t.Fatalf("%s wave recorded no request", c.wave)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestMultiSystemEndpointIsUnchangedOnBothWaves is the control on the SAME book: a provider that
|
||||
// declares nothing still receives the two-message wire, so the fix cannot have changed what every
|
||||
// other provider in the stack sees.
|
||||
func TestMultiSystemEndpointIsUnchangedOnBothWaves(t *testing.T) {
|
||||
rec := &reqRec{}
|
||||
srv := newJSONProvider(rec, func(body string) (string, string) {
|
||||
if isEditBody(body) {
|
||||
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД про Судзуки.", "stop"
|
||||
}
|
||||
return "Судзуки пошёл в тихую библиотеку.", "stop"
|
||||
})
|
||||
defer srv.Close()
|
||||
bookPath := setupProjectOpts(t, srv.URL, projectOpts{
|
||||
regenerate: 1, source: suzukiSource, glossarySeed: suzukiSeed,
|
||||
})
|
||||
|
||||
r := newRunner(t, bookPath)
|
||||
if _, err := r.TranslateBook(context.Background()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, body := range rec.all() {
|
||||
if sys := systemMessagesOf(t, body); len(sys) != 2 {
|
||||
t.Fatalf("an undeclared endpoint must keep the two-message wire, got %d: %+v", len(sys), sys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -485,7 +485,9 @@ func (r *Runner) runEditUnit(ctx context.Context, editSnapshot string, unit edit
|
|||
memberFlagged = true
|
||||
memberFlagReason = d.flagReason
|
||||
}
|
||||
continue // the flagged member is DROPPED from the edit — not skipped-whole-unit (c-lite)
|
||||
out.DroppedMembers++ // counted for EVERY drop, including when the edit itself flags too
|
||||
out.DroppedReason = memberFlagReason // the HOLE's cause, which out.FlagReason may not be
|
||||
continue // the flagged member is DROPPED from the edit — not skipped-whole-unit (c-lite)
|
||||
}
|
||||
cleanSources = append(cleanSources, m.Text)
|
||||
draftParts = append(draftParts, d.finalText)
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -22,7 +22,7 @@
|
|||
| Роль | Активный промт | Статус |
|
||||
|---|---|---|
|
||||
| Оркестратор | [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) | роль и нормы; счётчик роли — CURRENT-STATE |
|
||||
| Бэкенд | [BACKEND_SILENT_HARM_SESSION_PROMPT.md](BACKEND_SILENT_HARM_SESSION_PROMPT.md) | **ВЫДАН 28.08**, пак «тихая порча»: эскалация теряет инъекцию банка (208) · дыры выдачи молчат в выгруженном файле (193) · движковая идемпотентность повтора документа (§3.3). Состав — §3 промта. ⚠ **Второй рубеж ПРОТУХ:** опровергатель отработал (12 находок, включая вырожденный тест самопроверки) и лёг коммитом `3a65989`, но ПОСЛЕ него в промт добавлены три вещи, которых он не видел — §3.3 целиком (`9fd1d3e`), четвёртая дыра со своей границей и предупреждение о столкновении с `201` (`2c1fa9a`). Исполнителю это сказано прямо; его право отказа по §9 несёт этот рубеж. Четыре промта входной двери шва — в `archive/prompts/` (D39.158) |
|
||||
| Бэкенд | активного НЕТ | пак «тихая порча» ОТРАБОТАН, ПРИНЯТ и ЗАЛЕНДЖЕН 28.08 (**D39.164**): инъекция банка больше не теряется на провайдере с одним системным слотом, дыра выдачи видна читателю В ТЕКСТЕ, повтор принятого документа сходится. Промт — в `archive/prompts/`. ⚠ Заказ §3.2 был ПЕРЕ-ФОРМУЛИРОВАН находкой исполнителя (маркер существовал и врал), а подсказка промта про место правки ОТКЛОНЕНА им с грунтом — оба решения ратифицированы. Свободные строки зоны: **228** (алиас возвращает отклонённую поверхность), **230** (размен сходимости), **141**-остаток, **131**. Четыре промта входной двери шва — в `archive/prompts/` (D39.158) |
|
||||
| Платформа | активного НЕТ | пак **P9** ОТРАБОТАН, ПРИНЯТ и ЗАЛЕНДЖЕН 28.08 (**D39.162**): дверь правок банка смонтирована, ключи доехали, полоса стала сквозной вместе с каноном **0.6.0**. Промт — в `archive/prompts/`. ⚠ Открытым остался архитектурный стоп `PD-410` (платформа продаёт главы, движку идёт только `--ceiling-usd`) и продуктовый вопрос владельцу по `Н2`/`Н3`. Следующая работа зоны — по строкам **215**/**216** и регистру, запуск по слову владельца |
|
||||
| Полигон | [POLYGON_EXP2223_REDO_SESSION_PROMPT.md](POLYGON_EXP2223_REDO_SESSION_PROMPT.md) (отложенный — [POLYGON_PACKAGE4_SESSION_PROMPT.md](POLYGON_PACKAGE4_SESSION_PROMPT.md), строка 85) | фаза Д ИДЁТ; ⚠ живой носитель курса — в `eval/dovodka/`, какой именно называет зона (⚠ [POLYGON_PHASE_D_HANDOFF.md](POLYGON_PHASE_D_HANDOFF.md) — перекрытый снимок, читать не как курс) |
|
||||
| Фронт | активного НЕТ | **ЗОНА ЗАМОРОЖЕНА** (D39.136 п.2 + D39.147: разморозка отдельным словом владельца, не привязана к P7); перечень первого касания — в зонном журнале |
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Реестр D-нот — карта актуальности v2 (D1–D39.163;
|
||||
# Реестр D-нот — карта актуальности v2 (D1–D39.164;
|
||||
|
||||
> ⚠ **СЛАБОЕ МЕСТО, КОТОРОЕ БЫЛО ЗДЕСЬ (вписано 22.08, ЗАКРЫТО 24.08 — D39.157 п.6).** Колонка ТЕЛА
|
||||
> у нот D39.107…D39.123 говорила «жив», хотя тела уехали в слайс подрезкой D39.139; семнадцать строк
|
||||
|
|
@ -224,4 +224,5 @@
|
|||
| D39.161 | 27.08 | Контрактный минор **0.5.0** принят и заленджен: отменённая пер-термная модель снесена из канона и компаньона, дверь `POST …/bank/corrections` выведена из словаря `bank-apply` поле в поле, признак «не построено» машиночитаем, счётчики упразднённой модели сняты. Константа платформы поднята тем же коммитом — батарея зоны 18/EXIT=0. Ошибка промта про «поля навсегда нули» найдена исполнителем и вынесена `PD-399`. | ✅ |
|
||||
| D39.162 | 28.08 | **Платформенный пак P9 принят и заленджен + контрактный минор 0.6.0**: дверь правок банка смонтирована синхронно, ключи доехали `--keys-file` (строка 211 закрыта), дубль конвенции пути снят (213 сужена), полоса стала СКВОЗНОЙ вместе с каноном, `PD-399` снят. Батарея пере-прогнана приёмкой с живым Postgres (793/18/0/0). Две мои диспозиции сессия опровергла исполнением, я принял; одну её рекомендацию (закрыть 186) отклонил — жив остаток. Р3 — архитектурный стоп `PD-410`, канон НЕ смягчён. | ✅ |
|
||||
| D39.163 | 28.08 | **Граница контракта получает ВТОРОЕ исключение — `Progress.stage`**, ограниченное двумя условиями: значение ВЫВОДИТСЯ платформой из тех же счётчиков (не проброс из движка) и словарь ОТКРЫТ (клиент рисует незнакомое нейтрально, версия не поднимается). Ради канона мультиязычности: пара с иной формой работы не требует нового клиента. Оговорка по `PD-410`: подпись — модель платформы, не отчёт движка. Третьего исключения нота НЕ разрешает. | ✅ |
|
||||
| D39.164 | 28.08 | **Бэкенд-пак «тихая порча» принят и заленджен**: ось `SystemMessages` склеивает системный ряд у провайдера с одним слотом (инъекция банка перестала теряться), дыра выдачи видна в потоке текста, порядок записи решений сменён на rejects-first и повтор документа сходится. Приёмка — четырьмя посадками оркестратора, все пойманы топично. Заказ §3.2 пере-формулирован находкой исполнителя (маркер существовал и ВРАЛ), подсказка промта про место правки отклонена с грунтом и это ратифицировано. Строки 208 и 193 закрыты, 141 наполовину; заведены 228/229/230. | ✅ |
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
# Журнал решений оркестратора — контракт D1–D39.163 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
|
||||
# Журнал решений оркестратора — контракт D1–D39.164 (живой файл: карта · эрраты · живые тела · голова D39.124+ (подрезка D39.139); тела закрытых эр — в слайсах `docs/archive/architecture/`, указатель ниже; реестр всех нот — `05-decisions-index.md`)
|
||||
|
||||
> **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Работая с контрактом (греп номера: живой файл → слайсы, целиком НЕ читать — D39.125), держи под рукой, что чем перекрыто:
|
||||
> ⚠ **Эррата 09.08 (D39.125):** D39.111 п.1 предписывал промту S3 «максимум = баланс МИНУС открытые холды» — формула ОШИБОЧНА (вычитание дважды), исправлена D39.115 п.2(а): максимум = Balance КАК ЕСТЬ; тело D39.111 живёт ниже в этом файле (голова D39.106+).
|
||||
|
|
@ -906,3 +906,86 @@
|
|||
|
||||
**Что этой нотой НЕ разрешено:** третьего исключения нет, и «раз уж есть два» аргументом не будет.
|
||||
Каждое следующее — отдельная нота с собственным ограничением, иначе граница перестанет быть границей.
|
||||
|
||||
## D39.164 — БЭКЕНД-ПАК «ТИХАЯ ПОРЧА» ПРИНЯТ И ЗАЛЕНДЖЕН: инъекция банка больше не теряется, дыра выдачи видна читателю, повтор документа сходится (28.08, оркестратор №19). ✅
|
||||
|
||||
**Три пункта, все закрыты.** §3.1 — новая ось `llm.Capability.SystemMessages` (`multi`|`single`);
|
||||
под `single` ведущий системный ряд СКЛЕИВАЕТСЯ в одно сообщение, системное после не-системного хода
|
||||
отказывается ГРОМКО, а под `multi` (дефолт) отображение остаётся байт-равным прежнему проводу. §3.2 —
|
||||
дыра выдачи видна В ПОТОКЕ ТЕКСТА обоих рендеров. §3.3 — порядок записи документов решений сменён на
|
||||
REJECTS-FIRST, и повтор принятого документа сходится вместо отказа.
|
||||
|
||||
**Приёмка — четырьмя МОИМИ посадками мутаций, не чтением отчёта.** Каждая на резервной копии файла,
|
||||
каждая восстановлена побайтно (git не трогался: дерево грязное, рядом живёт чужая незакоммиченная
|
||||
работа).
|
||||
1. «ось игнорируется, склейки нет» → падают `internal/llm` (с текстом «a system message after a
|
||||
non-system turn must be refused») и `internal/pipeline` — обе волны;
|
||||
2. «снят гард непустого текста у маркера» → падают `TestExportPlaintextStateMatrix` и
|
||||
`TestTranslateStateMatrix`;
|
||||
3. «порядок возвращён к delta-first» → падают `TestHalfLandedIsNamedPerFile` и
|
||||
`TestTheRenameOrderIsWhatMakesADeclineRecover`;
|
||||
4. «боевое объявление квирка снято из `models.yaml`» → падает
|
||||
`TestShippedGeminiDeclaresOneSystemMessage` — гейт, которого до пака НЕ БЫЛО ВОВСЕ (удаление двух
|
||||
строк боевого конфига оставляло модуль зелёным; самонаходка сессии).
|
||||
|
||||
**Жёсткие ограничения промта проверены отдельно и держатся:** `gapMarker` живёт ТОЛЬКО в
|
||||
`cmd/tmctl/render.go`, то есть маркер — проекция выгрузки, а не мутация финального текста;
|
||||
присвоение `FinalText` в `export.go` не тронуто; Anthropic-путь не тронут ни одной строкой;
|
||||
`exportVersion` не двигался; `dropped_members` аддитивно и `omitempty` — ось детерминизма чиста.
|
||||
|
||||
**Заказ §3.2 ПЕРЕ-ФОРМУЛИРОВАН находкой исполнителя, и это удешевило пак.** Маркер существовал и
|
||||
ВРАЛ: ветка «флагнут и с текстом» печатала «(leak cleaned, verify)» ЛЮБОМУ такому юниту, а c-lite
|
||||
member-drop попадал ровно в неё — читателю сообщали про косметическую чистку, которой не было.
|
||||
Предмет стал «перестать врать и покрыть непокрытое» вместо «завести маркер».
|
||||
⚠ **Подсказка МОЕГО промта про место правки была ОПАСНА, и исполнитель её отклонил с грунтом.** Я
|
||||
указывал `export.go` рядом с `ApplyHeading`; туда нельзя — провод судит `translated`/`withheld`
|
||||
предикатом «текст непуст», и маркер в пустом юните перевернул бы `withheld` → `translated`, то есть
|
||||
ровно ⛔-запрет того же промта. `ApplyHeading` этого не делает лишь потому, что на пустом тексте он
|
||||
no-op. Различение, которое исполнитель положил в основу и которое я ратифицирую: **титул — часть
|
||||
КНИГИ и обязан ехать на провод; маркер дыры — метаданные О тексте, и в поле, по которому провод
|
||||
судит, ему нельзя.**
|
||||
|
||||
**Изменённый тест — НЕ подгонка под зелень, проверено диффом и посадкой.**
|
||||
`TestHalfLandedIsNamedPerFile` кодировал в фикстуре порядок переименований, который заказ §3.3
|
||||
изменил. Фикстура пере-нацелена с пути rejects на путь delta; утверждаемое — «легла РОВНО одна
|
||||
половина, это ПРОВЕРЕННЫЕ байты, отчёт называет какая» — сохранено дословно и по числу условий, а сам
|
||||
порядок вынесен в новый `TestTheRenameOrderIsWhatMakesADeclineRecover` с причиной. Моя посадка №3
|
||||
роняет ОБА теста. Исполнитель объявил правку САМ и первой строкой — это довод в его пользу, а не
|
||||
подозрение.
|
||||
|
||||
**Опровергатель окупился, и это главное свойство пака.** Шесть линз, тридцать находок, двадцать
|
||||
четыре доказаны исполнением; шесть настоящих дефектов — и ЧЕТЫРЕ из них внёс сам пак. Один
|
||||
показателен: первая редакция ветки маркера срабатывала без проверки непустого текста, то есть юнит с
|
||||
выпавшими ВСЕМИ членами получил бы баннер «фрагмент отсутствует НИЖЕ», указывающий на несуществующий
|
||||
текст — одна ложь читателю едва не заменилась другой.
|
||||
|
||||
**Право §9 применено трижды и один раз ОШИБОЧНО — исполнителем же и названо.** Отклонены: Д3 (символ
|
||||
живёт в `platform/`, движковая половина здорова — порядок «карта → память» исполнен), Д1 как отдельный
|
||||
предмет, подсказка про место маркера. Ошибочно: «вторую дверь не трогаю, это устаревший документ, а не
|
||||
ретрай» — опровергатель показал траекторию из одних принятых вызовов, рассуждение было неверным, дверь
|
||||
починена.
|
||||
|
||||
**Хард-блокер, найденный и снятый, — он достался бы следующей сессии как «сломанная сборка».**
|
||||
`go test ./cmd/tmctl/` строил бинарь во временном каталоге и НИКОГДА его не убирал: накопилось 249
|
||||
брошенных каталогов на ~5 ГБ, `/tmp` заполнился, батарея под `-race` начала падать с «no space left on
|
||||
device». Починено `TestMain`, проверено двумя прогонами подряд с нулевым приростом.
|
||||
|
||||
**Строки бэклога.** ЗАКРЫТЫ **208** (эскалационный хоп терял инъекцию) и **193** (дыры выдачи молчат
|
||||
в тексте). **141** — половина закрыта: сноска С ханьцзы теперь видна читателю тем же маркером
|
||||
бесплатно; остаток — правка самого детектора, и он НЕ про потерю текста. Якорь **131** исправлен
|
||||
приёмкой (`memory.go:512-515` → `:518-521`). Заведены **228** (алиас возвращает отклонённую
|
||||
поверхность — узкая форма Д1), **229** (снапшот не фолдит модель внутренних гейтов) и **230**
|
||||
(размен «сходимость против поучения»). Бэклог 175 → 176.
|
||||
|
||||
⚠ **Дыра снапшота (229) ЛАТЕНТНА, и это установлено замером, а не рассуждением:** гейта
|
||||
`terminology` нет ни в одном конфиге репозитория, поэтому сегодня она не стоит денег. Лечить её этим
|
||||
паком было бы неверно вдвойне — фолд гейт-моделей сдвигает хеши и обесценивает чекпойнты, то есть
|
||||
починка латентного дефекта стоила бы пере-покупки.
|
||||
|
||||
⚠ **ОШИБКА ОРКЕСТРАТОРА при лендинге P9, названная здесь, чтобы не потерялась.** Коммит `58bae30`
|
||||
унёс ~103 строки журнала бэкенд-сессии под МОИМ сообщением: мы писали в `docs/PROGRESS.md` в общем
|
||||
окне read-modify-write, её предупреждение пришло, когда `git add` уже отработал. Содержимое ЦЕЛО (обе
|
||||
стороны проверили независимо, код не затронут), потеряна только атрибуция. Историю не переписывал —
|
||||
канон запрещает это, пока в дереве живут чужие незакоммиченные правки. **Механизм плохой, а не
|
||||
человек:** зонный журнал — единственный файл, который две сессии правят одновременно, и защиты у него
|
||||
нет никакой.
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue