diff --git a/backend/internal/config/pipeline.go b/backend/internal/config/pipeline.go index 052036ef..dfeea145 100644 --- a/backend/internal/config/pipeline.go +++ b/backend/internal/config/pipeline.go @@ -6,6 +6,7 @@ import ( "os" "path/filepath" "strings" + "unicode" "gopkg.in/yaml.v3" ) @@ -254,11 +255,12 @@ type Gates struct { // TerminologyGate controls the TERMINOLOGIST role (pack-20, D39.42 п.1): between the draft wave and the // edit wave, one cheap model reads the WHOLE book's evidence for every bank candidate at once — the merged -// miner∪banknote list, the source contexts of each occurrence, the renderings the drafts produced, the -// pair's transliteration convention and its genre glossary — and returns ONE consolidated rendering per -// term. It exists because neither existing channel answers that question: the miner is source-side and -// emits no dst at all, and the banknote's per-chunk guesses named an overlapping entity in 3% of the -// mini-run's terms. +// miner∪banknote list, the source contexts of each occurrence, the renderings the drafts produced and the +// book's own SIGNED rows — and returns ONE consolidated rendering per term. (A pair-wide genre glossary +// was a fourth input here and was removed as a class by D39.47: prescribing one register to every book of +// a pair overrides the only authority that exists — the owner's signature on THIS book's bank.) It exists +// because neither existing channel answers that question: the miner is source-side and emits no dst at +// all, and the banknote's per-chunk guesses named an overlapping entity in 3% of the mini-run's terms. // // Opt-in and OFF by default, like every other gate here. With it off the bank-mining stop takes exactly // the path it took before (WHICH-only terms plus whatever the banknote join attached), no provider is @@ -274,10 +276,20 @@ type TerminologyGate struct { BudgetUSD float64 `yaml:"budget_usd"` // BatchRunes bounds ONE call's candidate block. 0 → terminologyDefaultBatchRunes. BatchRunes int `yaml:"batch_runes"` - // KWICPerTerm / KWICWidth size the source contexts each candidate carries. 0 → the engine defaults. - // They are the whole reason the role can translate at all, and the whole reason a call is not free. + // KWICPerTerm / KWICWidth size the source contexts each candidate carries. 0 → the pair's own sizing + // (langpack terminology.txt), else the engine defaults. They are the whole reason the role can + // translate at all, and the whole reason a call is not free. KWICPerTerm int `yaml:"kwic_per_term"` KWICWidth int `yaml:"kwic_width"` + // TargetScript is the Unicode SCRIPT NAME of the target language ("Cyrillic", "Latin", "Han", …) the + // answer-language screen checks renderings against. REQUIRED when the gate is on, by the same contract + // as the budget and the prompt path: the failure it guards is silent by construction, so a run with + // the screen disabled would bank a foreign-language canon and report nothing. + // + // It lives in the gate — which is NOT snapshot-folded — rather than in the langpack, whose bytes move + // LangpackVersion and re-buy both waves of every book of the pair. The banknote fold reads the same + // declaration, so a book running that channel without the terminologist may set it with the gate off. + TargetScript string `yaml:"target_script"` // PromptPath is the RESOLVED role prompt (`//terminologist.md`), filled by // LoadPipeline by the ordinary role convention. Not a config key. PromptPath string `yaml:"-"` @@ -992,6 +1004,13 @@ func LoadPipeline(path string, models *Models, pair string, labels []string) (*P if tg.BudgetUSD <= 0 { bad("gates.terminology.budget_usd must be > 0 when the gate is enabled (a gate that can never spend is a silent no-op)") } + // Validated against the same standard-library table the runner resolves with + // (terminology.ScriptByName), so an accepted name can never fail to resolve later. + if tg.TargetScript == "" { + bad("gates.terminology.target_script is required when the gate is enabled — the Unicode script name of the target language (e.g. Cyrillic | Latin | Han); without it a reply in the wrong language is banked as this book's canon in silence") + } else if _, ok := unicode.Scripts[tg.TargetScript]; !ok { + bad("gates.terminology.target_script %q is not a Unicode script name (exact spelling, e.g. Cyrillic | Latin | Han | Hiragana | Katakana | Hangul | Greek | Arabic)", tg.TargetScript) + } if pair == "" { bad("gates.terminology is enabled but the book declares no language pair — the role prompt is resolved as //terminologist.md") } else { diff --git a/backend/internal/config/prompt_pack_test.go b/backend/internal/config/prompt_pack_test.go index 8278f2a4..0b1b3037 100644 --- a/backend/internal/config/prompt_pack_test.go +++ b/backend/internal/config/prompt_pack_test.go @@ -427,3 +427,47 @@ func TestRepairGateValidation(t *testing.T) { t.Fatalf("a disabled repair gate must not be validated: %v", err) } } + +// TestTerminologyGateValidatesItsTargetScript pins the load-time contract of the answer-language screen: +// an enabled gate must declare the target language's script, and the name must be one Unicode defines. +// The failure it guards is silent at runtime, so it has to be loud before any money moves. +func TestTerminologyGateValidatesItsTargetScript(t *testing.T) { + dir := promptProject(t, "zh-ru") + if err := os.WriteFile(filepath.Join(dir, "prompts", "zh-ru", "terminologist.md"), []byte("role\n---USER---\n{{text}}\n"), 0o600); err != nil { + t.Fatal(err) + } + gate := func(extra string) string { + return "\ngates:\n terminology:\n enabled: true\n model: fake\n budget_usd: 1\n" + extra + } + cases := []struct{ name, gates, want string }{ + {"missing script", gate(""), "target_script is required"}, + {"unknown script", gate(" target_script: Cyrilic\n"), "not a Unicode script name"}, + {"case must be exact", gate(" target_script: cyrillic\n"), "not a Unicode script name"}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := LoadPipeline(draftOnlyConfig(t, dir, c.gates), miniModels(t), "zh-ru", nil) + if err == nil { + t.Fatal("a terminology gate that cannot screen the answer language must fail loud") + } + if !strings.Contains(err.Error(), c.want) { + t.Fatalf("error must explain %q, got: %v", c.want, err) + } + }) + } + p, err := LoadPipeline(draftOnlyConfig(t, dir, gate(" target_script: Cyrillic\n")), miniModels(t), "zh-ru", nil) + if err != nil { + t.Fatalf("a declared script must load: %v", err) + } + if p.Gates.Terminology.TargetScript != "Cyrillic" { + t.Fatalf("the declaration must reach the gate, got %q", p.Gates.Terminology.TargetScript) + } + // A pair that is not in the repo declares another script the same way — no Go edit involved. + if _, err := LoadPipeline(draftOnlyConfig(t, dir, gate(" target_script: Hiragana\n")), miniModels(t), "zh-ru", nil); err != nil { + t.Fatalf("any Unicode script must be declarable: %v", err) + } + // A DISABLED gate is never validated. + if _, err := LoadPipeline(draftOnlyConfig(t, dir, "\ngates:\n terminology:\n enabled: false\n"), miniModels(t), "zh-ru", nil); err != nil { + t.Fatalf("a disabled terminology gate must not be validated: %v", err) + } +} diff --git a/backend/internal/lang/langpack.go b/backend/internal/lang/langpack.go index 98dd1fbe..4f08bea3 100644 --- a/backend/internal/lang/langpack.go +++ b/backend/internal/lang/langpack.go @@ -32,6 +32,11 @@ import ( // you change the data, because the data's bytes ARE the version (the text/norm.go drift-proofing). // v2 (pair-14): new pair/source files (title-formant, sentence-terminator, palladius-phonotactics, dc-checkers) // + new formats (the pattern/category rows, the generic Palladius parser) — a schema change, so the tag bumps. +// +// "Schema change" means the READING of bytes that already exist changes. Adding a file that every current +// pair lacks does NOT: an absent optional file writes nothing into the hash, so those packs load to the +// identical version and nobody is re-billed for a mechanism they do not use. Bump the tag when a REQUIRED +// file joins the manifest, or when any existing file starts parsing differently. const packAlgoVersion = "langpack-v2" // Pack is a loaded, versioned language-data pack for one source→target pair. Fields are the DATA the @@ -77,9 +82,22 @@ type Pack struct { // data/algorithm boundary the miner tables keep. Heading *HeadingRule + // Terminology is the OPTIONAL pair sizing of the terminologist's source contexts + // (configs/langpacks//terminology.txt); nil when the pair ships no file. It exists because the + // KWIC width is measured in RUNES, and N runes buy a different amount of context in every script, so + // the number is a fact about the pair and belongs in pair data rather than in a Go constant. + Terminology *TerminologySizing + version string } +// TerminologySizing is the pair's own "how much source context is one context". A zero field means +// unstated and falls through to the engine default; the order lives in pipeline.terminologyOpts. +type TerminologySizing struct { + KWICPerTerm int + KWICWidth int +} + // Palladius is the pair transliteration table: the pinyin→Cyrillic maps and the syllable-generator's // phonotactic constraint sets (owner addendum 24.07 — one typed struct instead of four+three parallel Pack // fields). Populated from palladius.txt (Initials/Finals/YW/SpecialI) + palladius-phonotactics.txt @@ -223,6 +241,22 @@ func Load(root, sourceLang, targetLang string) (*Pack, error) { p.DCCheckers = dc } + // Optional per-pair TERMINOLOGIST sizing. Same contract as heading.txt: ABSENT → nil, the engine + // defaults apply and Version() is byte-stable, so shipping the mechanism re-bills nobody. A pair whose + // measured value equals the default deliberately ships no file — the bytes would move the pack version + // (and with it both waves of every book of the pair) to change nothing. + if tb, ok, terr := readOptional(root, pair, "terminology.txt"); terr != nil { + return nil, fmt.Errorf("langpack %q terminology.txt: %w", pair, terr) + } else if ok { + h.Write([]byte("\x00" + pair + "/terminology.txt\x00")) + h.Write(tb) + ts, perr := parseTerminology(tb) + if perr != nil { + return nil, fmt.Errorf("langpack %q terminology.txt: %w", pair, perr) + } + p.Terminology = ts + } + // A per-pair GENRE glossary was read here (pack-20 / D39.42 п.1) and was removed by D39.47: a pair-wide // file of "the industry rendering" prescribes ONE register to every book of the pair, and the choice // between «культивация» and «совершенствование» belongs to the owner's signature on a book's bank — @@ -554,6 +588,42 @@ func parseHeading(b []byte) (*HeadingRule, error) { return hr, nil } +// parseTerminology reads terminology.txt into a TerminologySizing. Format: `keyvalue`, keys +// kwic_per_term | kwic_width, each optional but positive when present. Fail-loud like parseHeading; an +// empty file is refused too, because its bytes move Version() while changing nothing. +func parseTerminology(b []byte) (*TerminologySizing, error) { + ts := &TerminologySizing{} + seen := false + for i, raw := range strings.Split(string(b), "\n") { + t := strings.TrimRight(raw, "\r") + if strings.TrimSpace(t) == "" || strings.HasPrefix(strings.TrimSpace(t), "#") { + continue + } + f := strings.SplitN(t, "\t", 2) + if len(f) != 2 || strings.TrimSpace(f[1]) == "" { + return nil, fmt.Errorf("line %d: want `keyvalue` with a non-empty value (%q)", i+1, t) + } + key, val := strings.TrimSpace(f[0]), strings.TrimSpace(f[1]) + n, err := strconv.Atoi(val) + if err != nil || n <= 0 { + return nil, fmt.Errorf("line %d: %s must be a positive integer (%q)", i+1, key, val) + } + switch key { + case "kwic_per_term": + ts.KWICPerTerm = n + case "kwic_width": + ts.KWICWidth = n + default: + return nil, fmt.Errorf("line %d: unknown key %q (want kwic_per_term|kwic_width)", i+1, key) + } + seen = true + } + if !seen { + return nil, fmt.Errorf("the file states nothing (want at least one of kwic_per_term|kwic_width); delete it instead — an empty table moves the pack version without changing behaviour") + } + return ts, nil +} + // runeSet reads a rune SET: every non-whitespace rune of every non-comment line is a member (order-free). func runeSet(b []byte) map[rune]bool { m := map[rune]bool{} diff --git a/backend/internal/lang/langpack_test.go b/backend/internal/lang/langpack_test.go index a335c718..c89b3a3e 100644 --- a/backend/internal/lang/langpack_test.go +++ b/backend/internal/lang/langpack_test.go @@ -439,6 +439,58 @@ func TestPairDataEditMovesThePackVersion(t *testing.T) { } } +// TestTerminologySizingIsOptionalPairData pins both halves of the optional contract: a pair that ships no +// file is byte-stable (so shipping the mechanism re-bills nobody, which is why zh-ru deliberately ships +// none), and a pair that does ship one folds its bytes like every other authored file. +func TestTerminologySizingIsOptionalPairData(t *testing.T) { + root := t.TempDir() + for _, dir := range []string{"zh", "zh-ru"} { + copyPackDir(t, filepath.Join("../../configs/langpacks", dir), filepath.Join(root, dir)) + } + p1, err := Load(root, "zh", "ru") + if err != nil { + t.Fatal(err) + } + if p1.Terminology != nil { + t.Fatal("the shipped pair ships no terminology.txt: its measured sizing equals the engine default, and the bytes would move the pack version for nothing") + } + shipped, err := Load("../../configs/langpacks", "zh", "ru") + if err != nil { + t.Fatal(err) + } + if shipped.Version() != p1.Version() { + t.Fatalf("the copy and the shipped pack must hash identically: %s vs %s", shipped.Version(), p1.Version()) + } + // The tag itself is the other half of "nobody is re-billed": adding an optional file changes no + // existing pack's bytes, so bumping the tag here would move every pair's version for nothing. + if packAlgoVersion != "langpack-v2" { + t.Fatalf("the pack algorithm tag moved to %q — that re-bills both waves of every book of every pair, so it must be a deliberate decision, not a side effect of adding an optional file", packAlgoVersion) + } + + path := filepath.Join(root, "zh-ru", "terminology.txt") + if err := os.WriteFile(path, []byte("# pair sizing\nkwic_per_term\t5\nkwic_width\t120\n"), 0o644); err != nil { + t.Fatal(err) + } + p2, err := Load(root, "zh", "ru") + if err != nil { + t.Fatal(err) + } + if p2.Terminology == nil || p2.Terminology.KWICPerTerm != 5 || p2.Terminology.KWICWidth != 120 { + t.Fatalf("the pair sizing must be parsed, got %+v", p2.Terminology) + } + if p2.Version() == p1.Version() { + t.Fatal("a present pair file MUST fold into the version, like every other authored file") + } + for _, bad := range []string{"", "# only a comment\n", "kwic_width\t0\n", "kwic_width\twide\n", "kwick\t3\n"} { + if err := os.WriteFile(path, []byte(bad), 0o644); err != nil { + t.Fatal(err) + } + if _, err := Load(root, "zh", "ru"); err == nil { + t.Fatalf("a corrupt or empty table must fail loud at load, %q did not", bad) + } + } +} + func copyPackDir(t *testing.T, from, to string) { t.Helper() if err := os.MkdirAll(to, 0o755); err != nil { diff --git a/backend/internal/pipeline/bankfold_test.go b/backend/internal/pipeline/bankfold_test.go new file mode 100644 index 00000000..d9a1e59f --- /dev/null +++ b/backend/internal/pipeline/bankfold_test.go @@ -0,0 +1,101 @@ +package pipeline + +import ( + "testing" + "unicode" + + "textmachine/backend/internal/store" + "textmachine/backend/internal/terminology" +) + +// stateWithProposals builds one per-chunk telemetry row carrying the given banknote lines. +func stateWithProposals(chapter, chunk int, lines ...bankEntry) store.RetrievalState { + return store.RetrievalState{Chapter: chapter, ChunkIdx: chunk, BanknoteDetail: bankProposalsJSON(lines)} +} + +// TestBankFoldJoinsEnclosedAndBareSurfaces pins the measured defect: one draft wave proposed the same +// title twice, once wrapped in paired marks and once bare, with two different renderings. Unfolded that is +// two rows of the owner's signature map, and neither joins the miner's candidate space. +func TestBankFoldJoinsEnclosedAndBareSurfaces(t *testing.T) { + obs := bankObservedByKey([]store.RetrievalState{ + stateWithProposals(1, 0, bankEntry{Src: "《咏梅》", Dst: "Ода сливе", Type: "title"}), + stateWithProposals(1, 1, bankEntry{Src: "咏梅", Dst: "Воспевая сливу", Type: "title"}), + }, nil) + if len(obs) != 1 { + t.Fatalf("the two surfaces must fold into ONE candidate, got %d: %+v", len(obs), obs) + } + if obs[0].Key != "咏梅" || obs[0].Src != "咏梅" { + t.Fatalf("key and displayed surface must both be the bare form, got key=%q src=%q", obs[0].Key, obs[0].Src) + } + if len(obs[0].Proposals) != 2 { + t.Fatalf("both renderings must survive as alternatives for the owner, got %+v", obs[0].Proposals) + } +} + +// TestBankFoldScreensTheAnswerLanguage pins the draft side of the answer-language screen: a rendering in +// another script never becomes evidence, and the same rows pass untouched when no script is declared. +func TestBankFoldScreensTheAnswerLanguage(t *testing.T) { + states := []store.RetrievalState{stateWithProposals(1, 0, + bankEntry{Src: "元海", Dst: "Primordial Sea", Type: "term"}, + bankEntry{Src: "方源", Dst: "Фан Юань", Type: "name"}, + )} + f := newBankFold(unicode.Scripts["Cyrillic"]) + addRetrievalStates(f, states) + obs := f.observed() + if len(obs) != 1 || obs[0].Key != "方源" { + t.Fatalf("only the target-language proposal may become evidence, got %+v", obs) + } + if f.OffLanguage != 1 { + t.Fatalf("the dropped proposal must be counted, got %d", f.OffLanguage) + } + if inert := bankObservedByKey(states, nil); len(inert) != 2 { + t.Fatalf("with no declared script the fold must be inert, got %+v", inert) + } +} + +// TestBankFoldCountsChunksNotRows is the aggregation invariant in miniature: the same chunk seen twice +// (a re-drafted chunk keeps both checkpoints) votes ONCE, so a union never inflates the evidence a +// consolidation is ranked on, while a genuinely different chunk does add its vote. +func TestBankFoldCountsChunksNotRows(t *testing.T) { + prop := []bankProposal{{SrcKey: "方源", Src: "方源", Dst: "Фан Юань", Type: "name"}} + f := newBankFold(nil) + f.add(1, 0, prop) + f.add(1, 0, prop) // the same chunk, drafted a second time + if got := f.observed()[0].Proposals[0].Chunks; got != 1 { + t.Fatalf("one chunk must count once however many stored answers mention it, got %d", got) + } + f.add(1, 1, prop) + if got := f.observed()[0].Proposals[0].Chunks; got != 2 { + t.Fatalf("a second chunk must add its own vote, got %d", got) + } +} + +// TestBankFoldIsDeterministic pins that neither the key order nor the rendering order depends on map +// iteration — the signature map must be byte-identical between two runs over the same evidence. +func TestBankFoldIsDeterministic(t *testing.T) { + states := []store.RetrievalState{ + stateWithProposals(1, 0, bankEntry{Src: "花家", Dst: "Дом Хуа", Type: "name"}), + stateWithProposals(1, 1, bankEntry{Src: "方源", Dst: "Фан Юань", Type: "name"}), + stateWithProposals(1, 2, bankEntry{Src: "方源", Dst: "Фан Юань", Type: "name"}), + stateWithProposals(1, 3, bankEntry{Src: "方源", Dst: "Фан-Юань", Type: "name"}), + } + var first []terminology.Observed + for i := 0; i < 8; i++ { + got := bankObservedByKey(states, nil) + if i == 0 { + first = got + continue + } + if len(got) != len(first) { + t.Fatalf("unstable fold size: %d vs %d", len(got), len(first)) + } + for j := range got { + if got[j].Key != first[j].Key || got[j].Proposals[0].Dst != first[j].Proposals[0].Dst { + t.Fatalf("unstable order at %d: %+v vs %+v", j, got[j], first[j]) + } + } + } + if first[0].Key != "方源" || first[0].Proposals[0].Dst != "Фан Юань" { + t.Fatalf("keys sort, renderings rank most-proposed-first: %+v", first) + } +} diff --git a/backend/internal/pipeline/banknote.go b/backend/internal/pipeline/banknote.go index 3b12aaf3..b9765913 100644 --- a/backend/internal/pipeline/banknote.go +++ b/backend/internal/pipeline/banknote.go @@ -12,7 +12,6 @@ import ( "textmachine/backend/internal/chunk" "textmachine/backend/internal/config" - "textmachine/backend/internal/miner" "textmachine/backend/internal/store" "textmachine/backend/internal/terminology" "textmachine/backend/internal/text" @@ -276,13 +275,8 @@ type bankProposal struct { Type string `json:"t"` } -// bankProposalsJSON serializes a chunk's parsed entries for retrieval_state.banknote_detail. Empty -// entries → "" (the column stays empty on every channel-off chunk, so a banknote-free book's row bytes -// are unchanged). Deterministic: the parser's line order is preserved, nothing is sorted by map order. -func bankProposalsJSON(entries []bankEntry) string { - if len(entries) == 0 { - return "" - } +// bankProposalsOf keys a chunk's parsed entries. Deterministic: the parser's line order is preserved. +func bankProposalsOf(entries []bankEntry) []bankProposal { out := make([]bankProposal, 0, len(entries)) for _, e := range entries { key := text.NormalizeSourceKey(e.Src) @@ -291,6 +285,13 @@ func bankProposalsJSON(entries []bankEntry) string { } out = append(out, bankProposal{SrcKey: key, Src: e.Src, Dst: strings.TrimSpace(e.Dst), Type: e.Type}) } + return out +} + +// bankProposalsJSON serializes them for retrieval_state.banknote_detail. Empty → "" (the column stays +// empty on every channel-off chunk, so a banknote-free book's row bytes are unchanged). +func bankProposalsJSON(entries []bankEntry) string { + out := bankProposalsOf(entries) if len(out) == 0 { return "" } @@ -301,68 +302,75 @@ func bankProposalsJSON(entries []bankEntry) string { return string(b) } -// bankProposalsByKey folds every chunk's stored proposals into src-key → proposals, counting how many -// chunks proposed each rendering. The COUNT is the signal the mini-run surfaced: the same term came back -// with three different renderings across chunks (学堂家老 → «старейшина школы» / «старейшина-наставник» / -// «учитель-старейшина»), which is precisely the drift a canon exists to close — so the owner sees the -// alternatives and their weight, not one arbitrary winner. -func bankProposalsByKey(states []store.RetrievalState) map[string][]miner.DstProposal { - obs := bankObservedByKey(states) - out := make(map[string][]miner.DstProposal, len(obs)) - for _, o := range obs { - list := make([]miner.DstProposal, 0, len(o.Proposals)) - for _, p := range o.Proposals { - list = append(list, miner.DstProposal{Dst: p.Dst, Type: p.Type, Chunks: p.Chunks}) - } - out[o.Key] = list - } - return out +// bankFold accumulates the draft side of the bank over every sampling of the book that exists. It is the +// one place a proposal becomes evidence, so the three disciplines apply here: the enclosure trim, the +// answer-language screen, and per-CHUNK counting. +type bankFold struct { + target *unicode.RangeTable // nil → the language screen is inert + votes map[string]*bankAgg + seen map[bankVote]bool + OffLanguage int } -// bankObservedByKey is the ONE fold of the durable per-chunk banknote rows into the draft-side view of -// each source surface: its key, the surface as the model wrote it, and every rendering with the number of -// chunks that proposed it. Both consumers read it — the signature-map join (through bankProposalsByKey) -// and the terminologist's merge, which additionally needs the SURFACE, so that a proposal for a term the -// miner never found can appear in the reverse section instead of being silently dropped on the floor. -// -// Deterministic throughout: the output slice is key-ordered and each key's renderings are ordered -// most-proposed-first with ties broken on the rendering itself — never on map order. -func bankObservedByKey(states []store.RetrievalState) []terminology.Observed { - type agg struct { - src string - typ string - byDst map[string]*terminology.Proposal - } - votes := map[string]*agg{} - for _, rs := range states { - if rs.BanknoteDetail == "" { +type bankAgg struct { + src string + typ string + byDst map[string]*terminology.Proposal +} + +// bankVote is one chunk's vote for one rendering, so a count means "how many chunks proposed this" and +// not "how many stored rows mention it" — they diverge the moment a chunk is re-drafted. +type bankVote struct { + chapter, chunk int + key, dst string +} + +func newBankFold(target *unicode.RangeTable) *bankFold { + return &bankFold{target: target, votes: map[string]*bankAgg{}, seen: map[bankVote]bool{}} +} + +// add folds one chunk's parsed proposals; src/dst arrive as the model wrote them. +func (f *bankFold) add(chapter, chunk int, props []bankProposal) { + for _, p := range props { + // Key and displayed surface are trimmed together: a decorated surface beside a bare key would be + // one thing described two ways, and no matcher will ever see the decoration in the source. + key, src := text.TrimEnclosure(p.SrcKey), text.TrimEnclosure(p.Src) + if key == "" || strings.TrimSpace(p.Dst) == "" { continue } - var props []bankProposal - if json.Unmarshal([]byte(rs.BanknoteDetail), &props) != nil { - continue // a corrupt detail blob is observability, never a reason to fail a run + if terminology.OffLanguage(p.Dst, f.target) { + f.OffLanguage++ + continue } - for _, p := range props { - a := votes[p.SrcKey] - if a == nil { - a = &agg{src: p.Src, typ: p.Type, byDst: map[string]*terminology.Proposal{}} - votes[p.SrcKey] = a - } - if cur := a.byDst[p.Dst]; cur != nil { - cur.Chunks++ - continue - } - a.byDst[p.Dst] = &terminology.Proposal{Dst: p.Dst, Type: p.Type, Chunks: 1} + v := bankVote{chapter, chunk, key, p.Dst} + if f.seen[v] { + continue } + f.seen[v] = true + a := f.votes[key] + if a == nil { + a = &bankAgg{src: src, typ: p.Type, byDst: map[string]*terminology.Proposal{}} + f.votes[key] = a + } + if cur := a.byDst[p.Dst]; cur != nil { + cur.Chunks++ + continue + } + a.byDst[p.Dst] = &terminology.Proposal{Dst: p.Dst, Type: p.Type, Chunks: 1} } - keys := make([]string, 0, len(votes)) - for k := range votes { +} + +// observed materializes the fold: key-ordered, each key's renderings most-proposed-first with ties broken +// on the rendering itself — never on map order. +func (f *bankFold) observed() []terminology.Observed { + keys := make([]string, 0, len(f.votes)) + for k := range f.votes { keys = append(keys, k) } sort.Strings(keys) out := make([]terminology.Observed, 0, len(keys)) for _, key := range keys { - a := votes[key] + a := f.votes[key] list := make([]terminology.Proposal, 0, len(a.byDst)) for _, p := range a.byDst { list = append(list, *p) @@ -378,6 +386,57 @@ func bankObservedByKey(states []store.RetrievalState) []terminology.Observed { return out } +// bankObservedByKey folds the durable per-chunk banknote rows into the draft-side view of each source +// surface. Both consumers read it: the signature-map join (proposalsFromObserved) and the terminologist's +// merge, which also needs the SURFACE so a proposal for a term the miner never found is not lost. +func bankObservedByKey(states []store.RetrievalState, target *unicode.RangeTable) []terminology.Observed { + f := newBankFold(target) + addRetrievalStates(f, states) + return f.observed() +} + +// bankObservedForBook is the draft side over EVERY sampling of the book that exists: the per-chunk +// telemetry rows plus every stored translator answer, including the ones a later run superseded. Both +// sources are folded into one accumulator, and votes are counted per chunk, so a chunk present in both +// (the normal case) counts once and the union only ever grows when new drafts were actually bought. +// +// Superseded attempts are included deliberately: a draft rejected for a defect in its TEXT does not make +// the terms it declared untrue, and nothing here enters the bank without a signature anyway. +func (r *Runner) bankObservedForBook() ([]terminology.Observed, int, error) { + f := newBankFold(r.targetScript) + states, err := r.Store.RetrievalStatesForBook(r.Book.BookID) + if err != nil { + return nil, 0, err + } + addRetrievalStates(f, states) + // Only answers that carry the channel's own marker can contribute, and a book's answer history is + // megabytes: the marker fragment (which a malformed separator still contains) bounds the read. + answers, err := r.Store.RoleResponsesForBook(r.Book.BookID, roleTranslator, bankMarkerFragment) + if err != nil { + return nil, 0, err + } + for _, a := range answers { + // The same slice/parse the live path runs, so the trust rule (a complete generation only) and the + // derived $0 rows are handled by one definition rather than by a second copy here. + _, _, _, entries := r.applyBanknoteWithEntries(roleTranslator, a.ResponseText, a.FinishReason) + f.add(a.Chapter, a.ChunkIdx, bankProposalsOf(entries)) + } + return f.observed(), f.OffLanguage, nil +} + +func addRetrievalStates(f *bankFold, states []store.RetrievalState) { + for _, rs := range states { + if rs.BanknoteDetail == "" { + continue + } + var props []bankProposal + if json.Unmarshal([]byte(rs.BanknoteDetail), &props) != nil { + continue // a corrupt detail blob is observability, never a reason to fail a run + } + f.add(rs.Chapter, rs.ChunkIdx, props) + } +} + // bankDerivedHash is the CONTENT-ADDRESSED id of a banknote-stripped export checkpoint (§4б EXACT // formula, mirroring commitSanitizedExport's namespacing): sha256("tm-banknote-v1\x00"+reqHash+"\x00"+ // stripped), prefixed "tm-banknote-v1:" so it can never collide with a real hex attempt hash and a diff --git a/backend/internal/pipeline/bankunion_test.go b/backend/internal/pipeline/bankunion_test.go new file mode 100644 index 00000000..1f220383 --- /dev/null +++ b/backend/internal/pipeline/bankunion_test.go @@ -0,0 +1,110 @@ +package pipeline + +import ( + "context" + "testing" + + "textmachine/backend/internal/store" + "textmachine/backend/internal/terminology" +) + +// TestBankUnionRecoversASupersededSampling is the aggregation contract end to end. The per-chunk +// telemetry row is keyed (book, chapter, chunk) and upserted, so a chunk drafted a second time OVERWRITES +// the proposals of the first — and that channel proposes a substantially different set on every sampling, +// so the overwrite loses real coverage. The checkpoints keep every answer, so the union must hold both. +// +// The second answer is written through the ordinary money path rather than simulated in the fold, so the +// test would catch a query that misses re-drafted attempts as surely as a fold that drops them. +func TestBankUnionRecoversASupersededSampling(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, func(body string) (string, string) { + if isEditBody(body) { + return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" + } + return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop" + }) + defer srv.Close() + r := newRunner(t, setupMiningStopProject(t, srv.URL, miningStopOpts{})) + defer r.Close() + if _, err := r.TranslateBook(context.Background()); err != nil { + t.Fatal(err) + } + + before, _, err := r.bankObservedForBook() + if err != nil { + t.Fatal(err) + } + if len(before) == 0 { + t.Fatal("setup: the first sampling must have produced proposals") + } + if got := keysOf(before); contains(got, "花家") { + t.Fatalf("setup: 花家 must not be proposed yet, got %v", got) + } + // Every proposal of this run came from one chunk, so the union must not have inflated any count. + for _, o := range before { + for _, p := range o.Proposals { + if p.Chunks != 1 { + t.Fatalf("a single sampling must count once per chunk, %s→%s has %d", o.Key, p.Dst, p.Chunks) + } + } + } + + // A SECOND answer for the same chunk, as a re-purchase produces: the telemetry row still holds the + // first one, the checkpoint store holds both. + snap, _, err := r.snapshotIDForWave(waveDraft) + if err != nil { + t.Fatal(err) + } + job, err := r.Store.EnsureJob(r.Book.BookID, 1, "draft", snap) + if err != nil { + t.Fatal(err) + } + res, verdict, err := r.Store.Reserve(r.Book.BookID, 0.01, store.Ceilings{BookUSD: 100, DayUSD: 100}) + if err != nil || verdict != store.ReserveOK { + t.Fatalf("reserve: %v %v", verdict, err) + } + second := "Фан Юань пришёл к горе Цинмао.\n" + bankSeparator + "\n花家\tДом Хуа\tname\n" + if err := r.Store.SettleWithCheckpoint(res, 0.01, store.Checkpoint{ + RequestHash: "second-sampling", JobID: job.ID, ChunkIdx: 0, Attempt: 1, + Stage: "draft", Role: roleTranslator, ModelRequested: "fake-model", ModelActual: "fake-model", + ResponseText: second, UsageJSON: "{}", CostUSD: 0.01, FinishReason: "stop", + }); err != nil { + t.Fatal(err) + } + + after, _, err := r.bankObservedForBook() + if err != nil { + t.Fatal(err) + } + if !contains(keysOf(after), "花家") { + t.Fatalf("the union must recover the superseded sampling, got %v", keysOf(after)) + } + if !contains(keysOf(after), "方源") { + t.Fatalf("the union must keep the surviving sampling too, got %v", keysOf(after)) + } + // The re-drafted chunk still votes once per rendering: two answers from ONE chunk are one chunk. + for _, o := range after { + for _, p := range o.Proposals { + if p.Chunks != 1 { + t.Fatalf("a re-drafted chunk must not vote twice, %s→%s has %d", o.Key, p.Dst, p.Chunks) + } + } + } +} + +func keysOf(obs []terminology.Observed) []string { + out := make([]string, 0, len(obs)) + for _, o := range obs { + out = append(out, o.Key) + } + return out +} + +func contains(ss []string, want string) bool { + for _, s := range ss { + if s == want { + return true + } + } + return false +} diff --git a/backend/internal/pipeline/mining.go b/backend/internal/pipeline/mining.go index 96266ab6..9dd9c201 100644 --- a/backend/internal/pipeline/mining.go +++ b/backend/internal/pipeline/mining.go @@ -80,15 +80,17 @@ func (r *Runner) runBankMiningStop(ctx context.Context, chunks []chunk.Chunk, dr // mode silently switches the stop off after its first run — see unsignedEngineSurfaces. mined, emission := miner.MineBankStats(minerChunks, contrast, unsignedEngineSurfaces(seed), rejects, miner.FrozenConfig(), r.pack) - // The WHAT side (D39.36 fix): the banknote proposals the draft wave collected are read off the durable - // per-chunk rows. They arrive as EVIDENCE — nothing proposed enters the bank without a signature. A read - // failure degrades to the previous WHICH-only map rather than blocking the stop: the map is what the - // owner needs, the dst is a bonus. - var observed []terminology.Observed - if states, serr := r.Store.RetrievalStatesForBook(r.Book.BookID); serr != nil { - r.Log.WarnContext(ctx, "bank-mining: could not read the banknote proposals; the signature map falls back to WHICH-only (bare terms)", "err", serr) - } else { - observed = bankObservedByKey(states) + // The WHAT side: the banknote proposals the draft waves collected, over every sampling of the book that + // exists. They arrive as EVIDENCE — nothing proposed enters the bank without a signature. A read failure + // degrades to the WHICH-only map rather than blocking the stop: the map is what the owner needs, the + // dst is a bonus. + observed, offLanguage, oerr := r.bankObservedForBook() + if oerr != nil { + r.Log.WarnContext(ctx, "bank-mining: could not read the banknote proposals; the signature map falls back to WHICH-only (bare terms)", "err", oerr) + } + if offLanguage > 0 { + r.Log.WarnContext(ctx, "bank-mining: draft-side proposals were written in another script and are NOT offered for signature", + "book", r.Book.BookID, "dropped", offLanguage, "target_script", r.Pipeline.Gates.Terminology.TargetScript) } // The TERMINOLOGIST (pack-20, D39.42): merge both channels, gather each candidate's source contexts, diff --git a/backend/internal/pipeline/miningstop_join_test.go b/backend/internal/pipeline/miningstop_join_test.go index ffd01d5d..43177551 100644 --- a/backend/internal/pipeline/miningstop_join_test.go +++ b/backend/internal/pipeline/miningstop_join_test.go @@ -61,6 +61,7 @@ type miningStopOpts struct { terminology bool // enable the terminologist role (pack-20) with a fixture prompt batchRunes int // gates.terminology.batch_runes; 0 = engine default (one batch for this corpus) budgetUSD float64 // gates.terminology.budget_usd; 0 = 1.0 (effectively unbounded here) + targetScript string // gates.terminology.target_script; "" = Cyrillic (the fixtures' target) } // bankBlockForMining is the banknote a translator emits over this corpus: one line whose src IS a mined @@ -83,7 +84,11 @@ func setupMiningStopProject(t *testing.T, providerURL string, o miningStopOpts) if budget <= 0 { budget = 1.0 } - gates += fmt.Sprintf(" terminology:\n enabled: true\n model: fake-model\n budget_usd: %g\n", budget) + script := o.targetScript + if script == "" { + script = "Cyrillic" // the fixtures translate into Russian; an enabled gate must declare a script + } + gates += fmt.Sprintf(" terminology:\n enabled: true\n model: fake-model\n budget_usd: %g\n target_script: %s\n", budget, script) if o.batchRunes > 0 { gates += fmt.Sprintf(" batch_runes: %d\n", o.batchRunes) } diff --git a/backend/internal/pipeline/minirun_fixes_test.go b/backend/internal/pipeline/minirun_fixes_test.go index a661c7eb..6d5451a1 100644 --- a/backend/internal/pipeline/minirun_fixes_test.go +++ b/backend/internal/pipeline/minirun_fixes_test.go @@ -165,7 +165,7 @@ func TestBanknoteProposalsArePersistedAndJoined(t *testing.T) { if len(props) != 2 { t.Fatalf("want 2 stored proposals, got %d: %s", len(props), rs.BanknoteDetail) } - byKey := bankProposalsByKey([]store.RetrievalState{*rs}) + byKey := proposalsFromObserved(bankObservedByKey([]store.RetrievalState{*rs}, nil)) if got := byKey[props[0].SrcKey]; len(got) == 0 || got[0].Dst != props[0].Dst { t.Fatalf("the fold must key proposals by the miner's normalized surface, got %+v", byKey) } diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go index 7445bb69..94b5e75d 100644 --- a/backend/internal/pipeline/runner.go +++ b/backend/internal/pipeline/runner.go @@ -8,6 +8,7 @@ import ( "path/filepath" "strings" "sync" + "unicode" "textmachine/backend/internal/checks" "textmachine/backend/internal/chunk" @@ -69,6 +70,10 @@ type Runner struct { // nil otherwise, which is what makes the whole role a no-op for every book that does not enable it. It is // NOT snapshot-folded: the role writes a signature map, not a checkpoint (config.TerminologyGate). terminologyTemplate *PromptTemplate + // targetScript is the target language's Unicode script, resolved once from gates.terminology + // .target_script (config validates the name). nil when the book declares none — the answer-language + // screen is then inert, which loadTargetScript says out loud if any channel could have used it. + targetScript *unicode.RangeTable // rateGuards is the per-model wave-concurrency guard set (WS1 §1б), built once in the precompute pass and // read-only in the waves — a transport axis, never snapshot-folded. nil until buildRateGuards. rateGuards map[string]*rateGuard @@ -242,6 +247,7 @@ func openRunner(bookPath string, logger *slog.Logger, forWrite bool) (*Runner, e st.Close() return nil, err } + r.loadTargetScript() // The echo-exposure half of the reasoning-off hole (D39.26 добор B): loud, not fatal — see // sourceEchoExposure. Needs the templates, so it runs after they load. if exposed := r.sourceEchoExposure(); len(exposed) > 0 { diff --git a/backend/internal/pipeline/terminologist.go b/backend/internal/pipeline/terminologist.go index 50e58f74..66a0effa 100644 --- a/backend/internal/pipeline/terminologist.go +++ b/backend/internal/pipeline/terminologist.go @@ -9,6 +9,7 @@ import ( "textmachine/backend/internal/chunk" "textmachine/backend/internal/config" + "textmachine/backend/internal/lang" "textmachine/backend/internal/ledger" "textmachine/backend/internal/llm" "textmachine/backend/internal/miner" @@ -70,6 +71,9 @@ type terminologyResult struct { Declined int // terms the role explicitly could not render → status:auto Unanswered int // terms no reply line covered (also status:auto — silence is not a decision) BadLines int // reply lines the parser refused + // OffLanguage counts refused lines whose rendering was not in the target's script — separate from + // BadLines because it means the model answered in another language, not that it broke the format. + OffLanguage int // CanonConflicts counts consolidated renderings that contradict a row the owner already signed. It is // OBSERVABILITY, never a gate: the rows stay unverified either way, and this is what tells the owner // which of them to look at first. @@ -94,11 +98,32 @@ func (r *Runner) loadTerminologyTemplate() error { return nil } -// terminologyOpts resolves the sizing knobs (config value, else the engine default) in ONE place, so the -// batching and the request rendering can never disagree about what a batch is. +// loadTargetScript resolves the answer-language screen's script once. config refuses an unknown name for +// an enabled gate, so an unresolved script here means the book simply declared none — inert, and said out +// loud when a channel that would have used it is on. +func (r *Runner) loadTargetScript() { + if name := r.Pipeline.Gates.Terminology.TargetScript; name != "" { + r.targetScript, _ = terminology.ScriptByName(name) + } + if r.targetScript == nil && r.Pipeline.Gates.Banknote.Enabled { + r.Log.Warn("the banknote channel is on but no gates.terminology.target_script is declared: draft-side proposals are NOT screened for the answer language, so a rendering in another script can enter the signature map and the auto-bank", + "book", r.Book.BookID) + } +} + +// terminologyOpts resolves the sizing knobs in ONE place — explicit config, else the pair's own data, +// else the engine default — so batching and rendering can never disagree about what a batch is. func (r *Runner) terminologyOpts() (batchRunes, kwicPer, kwicWidth int) { g := r.Pipeline.Gates.Terminology batchRunes, kwicPer, kwicWidth = g.BatchRunes, g.KWICPerTerm, g.KWICWidth + if pair := r.packTerminology(); pair != nil { + if kwicPer <= 0 { + kwicPer = pair.KWICPerTerm + } + if kwicWidth <= 0 { + kwicWidth = pair.KWICWidth + } + } if batchRunes <= 0 { batchRunes = terminologyDefaultBatchRunes } @@ -111,6 +136,13 @@ func (r *Runner) terminologyOpts() (batchRunes, kwicPer, kwicWidth int) { return batchRunes, kwicPer, kwicWidth } +func (r *Runner) packTerminology() *lang.TerminologySizing { + if r.pack == nil { + return nil + } + return r.pack.Terminology +} + // buildBankCandidates is the $0 half of the role: the two-way miner∪banknote merge, the source contexts, // and the §C2-3 ranking of whatever the drafts already produced. It runs whether or not the gate is on — // with the gate off nothing consumes the ranking, but building it costs nothing and it is what the stop's @@ -250,8 +282,16 @@ func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []te for _, c := range b { keys = append(keys, c.Key) } - got, bad := terminology.ParseReply(att.text, keys, text.NormalizeSourceKey) - res.BadLines += bad + got, st := terminology.ParseReply(att.text, keys, text.NormalizeSourceKey, r.targetScript) + res.BadLines += st.Bad + res.OffLanguage += st.OffLanguage + // A batch answered largely in another language is the measured cold-start failure, not noise: say + // it while the run is happening, naming the lines, because the terms themselves just stay auto. + if st.OffLanguage > 0 { + r.Log.WarnContext(ctx, "terminology: the model answered in another script; those lines are REFUSED (the terms stay unconsolidated) — a book with few signed rows gives the model no target-language anchor", + "book", r.Book.BookID, "batch", i, "terms", len(b), "off_language", st.OffLanguage, + "target_script", r.Pipeline.Gates.Terminology.TargetScript, "lines", strings.Join(st.OffLanguageSamples, "; ")) + } // A batch that came back with NOTHING usable is a paid call that bought no terminology: an empty // completion, a truncation, a refusal, a reply in prose. Silence here would spend the money, mark // every term of the batch "the role declined" (§C2-7's auto mode, which is a DECISION) and exit 0. @@ -259,7 +299,7 @@ func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []te // this step started from, not a broken one. if len(got) == 0 { r.Log.WarnContext(ctx, "terminology: a paid batch returned nothing the parser could use; its terms stay unconsolidated", - "book", r.Book.BookID, "batch", i, "terms", len(b), "bad_lines", bad, + "book", r.Book.BookID, "batch", i, "terms", len(b), "bad_lines", st.Bad, "reply_chars", len(att.text), "cost_usd", fmt.Sprintf("%.6f", att.runCost)) } for k, v := range got { @@ -291,7 +331,8 @@ func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []te } r.Log.InfoContext(ctx, "terminology finished", "book", r.Book.BookID, "consolidated", res.Consolidated, "declined", res.Declined, "unanswered", res.Unanswered, - "bad_lines", res.BadLines, "canon_conflicts", res.CanonConflicts, "cost_usd", fmt.Sprintf("%.6f", res.CostUSD)) + "bad_lines", res.BadLines, "off_language", res.OffLanguage, + "canon_conflicts", res.CanonConflicts, "cost_usd", fmt.Sprintf("%.6f", res.CostUSD)) return out, res, nil } diff --git a/backend/internal/store/ledger.go b/backend/internal/store/ledger.go index c8aa6ec4..8e2fbc40 100644 --- a/backend/internal/store/ledger.go +++ b/backend/internal/store/ledger.go @@ -262,6 +262,55 @@ func (s *Store) GetCheckpoint(requestHash string) (*Checkpoint, error) { return &cp, nil } +// RoleResponse is one persisted model answer with the chunk coordinates it was produced for — enough to +// re-derive anything the raw text carried, and nothing more (no usage, no money, no hashes). +type RoleResponse struct { + Chapter int + ChunkIdx int + Attempt int + ResponseText string + FinishReason string +} + +// RoleResponsesForBook returns EVERY persisted answer a role ever produced for a book, oldest job first — +// including the attempts a later run superseded. +// +// It exists for the banknote union. The per-chunk telemetry row is keyed (book, chapter, chunk) and +// UPSERTED, so when a chunk is genuinely re-drafted the new proposals overwrite the old ones — and that +// channel was measured to propose a substantially different set on every sampling, so the overwrite loses +// real coverage. Checkpoints keep every answer the book ever paid for, addressed by its own request hash, +// so the union is DERIVED rather than stored: no new table, no migration, self-healing like the row it +// supplements. +// +// It returns the $0 DERIVED rows too (the stripped export, the sanitized export), deliberately: they carry +// a non-provider finish_reason, so the caller's ordinary "trust a complete generation only" gate already +// excludes them, and filtering them here would put a second, silently drifting copy of that rule in SQL. +// +// mustContain is an optional VOLUME filter on the response text (empty = no filter). A book's whole +// answer history is megabytes and grows with every re-purchase, so a caller that only cares about answers +// carrying a marker passes it here instead of reading everything into memory. It is never a semantic +// rule — the caller still parses whatever it gets — and the marker stays a Go constant, passed as a +// value, so SQL holds no second copy of it. +// +// Ordering is (job, chunk, attempt) so a fold over the result is deterministic without sorting in Go. +func (s *Store) RoleResponsesForBook(bookID, role, mustContain string) ([]RoleResponse, error) { + q := `SELECT j.chapter, c.chunk_idx, c.attempt, c.response_text, c.finish_reason + FROM checkpoints c JOIN jobs j ON j.id = c.job_id + WHERE j.book_id = ? AND c.role = ?` + args := []any{bookID, role} + if mustContain != "" { + q += ` AND instr(c.response_text, ?) > 0` + args = append(args, mustContain) + } + q += ` ORDER BY j.id, c.chunk_idx, c.attempt` + return queryAll(s.r, q, + func(rows *sql.Rows) (RoleResponse, error) { + var r RoleResponse + err := rows.Scan(&r.Chapter, &r.ChunkIdx, &r.Attempt, &r.ResponseText, &r.FinishReason) + return r, err + }, args...) +} + // SpentUSD reports (committed, reserved) for a book across all days. func (s *Store) SpentUSD(bookID string) (committed, reserved float64, err error) { ctx, cancel := opContext() diff --git a/backend/internal/terminology/script.go b/backend/internal/terminology/script.go new file mode 100644 index 00000000..a8ec5167 --- /dev/null +++ b/backend/internal/terminology/script.go @@ -0,0 +1,46 @@ +package terminology + +import "unicode" + +// script.go: the answer-language guarantee. Measured on live replies: with no signed rows to show the +// model, the role answers in a foreign script — and every existing guard admits it, because a foreign +// script is legitimate for foreign proper names, the answer is not an echo of the source, and an empty +// canon anchor has nothing to contradict. Input-side fixes (the anchor, the drafts evidence, a format +// example in the prompt) lower the rate; a guarantee may not depend on the content of a request, so it +// lives on the output side. + +// ScriptByName resolves a Unicode script NAME ("Cyrillic", "Latin", "Han", …) to its range table. The +// table set is the standard library's, so the accepted spellings are Unicode's — this engine keeps no +// list of languages to fall out of date. config validates a declared name against the same map. +func ScriptByName(name string) (*unicode.RangeTable, bool) { + t, ok := unicode.Scripts[name] + return t, ok +} + +// OffLanguage reports whether dst carries letters yet not one of them belongs to the target script. +// +// The weakest rule that catches the measured failure: one target-script letter is enough to pass, so a +// mixed rendering is untouched, and a rendering with no letters at all is not judged here. +// +// Direction of error, declared: it can refuse a legitimate rendering a pair wants kept in foreign letters. +// That costs one proposal the owner can still sign by hand; the opposite mistake puts a foreign word in a +// book's canon. +// +// target == nil → inert, mirroring a nil ScoreOpts.Conformance. The loudness for that case belongs to +// config and to the runner, not here. +func OffLanguage(dst string, target *unicode.RangeTable) bool { + if target == nil { + return false + } + letters := false + for _, r := range dst { + if !unicode.IsLetter(r) { + continue + } + if unicode.Is(target, r) { + return false + } + letters = true + } + return letters +} diff --git a/backend/internal/terminology/script_test.go b/backend/internal/terminology/script_test.go new file mode 100644 index 00000000..49bd8a54 --- /dev/null +++ b/backend/internal/terminology/script_test.go @@ -0,0 +1,105 @@ +package terminology + +import ( + "strings" + "testing" + "unicode" +) + +// offLanguageReplies are renderings a live model actually returned when it lost the target language, and +// signedRenderings are renderings from a real signed bank plus the shapes most likely to be false +// positives (two letters, digits only, quoted, doubled space). Both lists are the acceptance material for +// the screen: every line of the first must be refused, every line of the second must pass. +var ( + offLanguageReplies = []string{"cultivation", "Primordial Sea", "Rank 1", "Grade C", "talent", + "Wine Bug", "realm", "Qingmao Mountain", "Gu Yue", "is"} + signedRenderings = []string{"культивация", "море истинной ци", "гу-мастер", "Фан Юань", "гу", + "талант", "винный червь", "старейшина", "Гуюэ", "апертура", "третья гу", "гу Силы", + "Река времени", "9", "«Ода сливе»", "Фан Юань"} +) + +func TestOffLanguageSeparatesForeignRenderingsFromTargetOnes(t *testing.T) { + cyr := unicode.Scripts["Cyrillic"] + for _, s := range offLanguageReplies { + if !OffLanguage(s, cyr) { + t.Errorf("%q must be refused: it carries letters and none of them are in the target script", s) + } + } + for _, s := range signedRenderings { + if OffLanguage(s, cyr) { + t.Errorf("%q is a legitimate rendering and must pass", s) + } + } + // A mixed rendering passes deliberately: one target-script letter is enough, so the screen never + // touches a rendering that carries a foreign name beside a target word. + if OffLanguage("Фан Юань (Fang Yuan)", cyr) { + t.Error("a mixed rendering must pass — the screen judges language, not spelling") + } + if OffLanguage("anything", nil) { + t.Error("with no declared script the screen must be inert") + } +} + +// TestOffLanguageIsTargetBlind is the generality pin: nothing about a language lives in this package, so +// swapping the declared script swaps every verdict and no Go edit is involved. +func TestOffLanguageIsTargetBlind(t *testing.T) { + lat := unicode.Scripts["Latin"] + if !OffLanguage("культивация", lat) || OffLanguage("cultivation", lat) { + t.Fatal("under a Latin target the verdicts must be exactly reversed") + } + // A pair with neither script: Han as a target refuses both of the above and accepts a Han rendering. + han := unicode.Scripts["Han"] + if !OffLanguage("культивация", han) || !OffLanguage("cultivation", han) || OffLanguage("元海", han) { + t.Fatal("a third target must work on the same code path") + } + if _, ok := ScriptByName("Cyrillic"); !ok { + t.Fatal("a valid script name must resolve") + } + if _, ok := ScriptByName("cyrillic"); ok { + t.Fatal("resolution must be exact, so a typo fails loudly at config load instead of disabling the screen") + } +} + +// TestParseReplyRefusesForeignLanguageRenderings pins the screen where it actually protects the bank: a +// reply mixing correct and foreign-language lines banks only the correct ones, counts the rest, and names +// them. Without the screen every line here is accepted and reaches the editor as this book's canon. +func TestParseReplyRefusesForeignLanguageRenderings(t *testing.T) { + id := func(s string) string { return s } + keys := []string{"修行", "元海", "转", "是为", "资质"} + reply := "修行\tcultivation\n元海\tPrimordial Sea\n转\trealm\n是为\tis\n资质\tталант" + + got, st := ParseReply(reply, keys, id, unicode.Scripts["Cyrillic"]) + if len(got) != 1 || got["资质"] != "талант" { + t.Fatalf("only the target-language line may be banked, got %#v", got) + } + if st.OffLanguage != 4 || st.Bad != 4 { + t.Fatalf("all four foreign lines must be refused AND counted, got off_language=%d bad=%d", st.OffLanguage, st.Bad) + } + if len(st.OffLanguageSamples) != 4 || !strings.Contains(st.OffLanguageSamples[0], "cultivation") { + t.Fatalf("the refused lines must be named for the operator, got %q", st.OffLanguageSamples) + } + // Without a declared script the same reply is banked whole — the state the engine was in before, kept + // visible so the cost of an undeclared script is a test, not a surprise. + if inert, st := ParseReply(reply, keys, id, nil); len(inert) != 5 || st.OffLanguage != 0 { + t.Fatalf("with no script the screen must be inert (that is what config refuses for an enabled gate), got %#v", inert) + } +} + +// TestOffLanguageSamplesAreBounded keeps a whole batch answered in the wrong language from turning one +// warning into the reply itself. +func TestOffLanguageSamplesAreBounded(t *testing.T) { + id := func(s string) string { return s } + var lines []string + var keys []string + for _, k := range []string{"a1", "a2", "a3", "a4", "a5", "a6", "a7", "a8", "a9", "b1", "b2", "b3"} { + keys = append(keys, k) + lines = append(lines, k+"\tforeign") + } + _, st := ParseReply(strings.Join(lines, "\n"), keys, id, unicode.Scripts["Cyrillic"]) + if st.OffLanguage != len(keys) { + t.Fatalf("every foreign line must be counted, got %d of %d", st.OffLanguage, len(keys)) + } + if len(st.OffLanguageSamples) != offLanguageSampleCap { + t.Fatalf("the samples must stop at %d, got %d", offLanguageSampleCap, len(st.OffLanguageSamples)) + } +} diff --git a/backend/internal/terminology/terminology.go b/backend/internal/terminology/terminology.go index 3473513d..9546dd30 100644 --- a/backend/internal/terminology/terminology.go +++ b/backend/internal/terminology/terminology.go @@ -831,33 +831,46 @@ func sharedRunes(a, b string) int { return n } +// ReplyStats is what one reply cost in unusable lines. Bad is the TOTAL refused; OffLanguage is the +// subset refused by the answer-language screen, kept apart because the two mean different things to an +// operator — a broken format against a run producing canon in the wrong language. +type ReplyStats struct { + Bad int + OffLanguage int + OffLanguageSamples []string // first few refused lines verbatim, so the warning names them +} + +const offLanguageSampleCap = 8 + // ParseReply turns the terminologist's reply into key → consolidated rendering. Lines are // ``; the src is matched through `normalize` against the batch's EXPECTED keys, so a line // for a term that was not asked about is ignored (a model inventing rows must not inject terms into the // bank) and a rendering written for a differently-spelled src still lands. NoDst yields an explicit empty // rendering — the two-mode emission's "no dst" branch, which is a decision, not a failure. // -// Returns the accepted map plus the count of unusable lines, which the caller logs: silence about a reply +// `target` is the target language's script (nil → the answer-language check is inert; see OffLanguage). +// +// Returns the accepted map plus the tally of unusable lines, which the caller logs: silence about a reply // the parser could not read is how a paid call turns into an empty bank with no signal. -func ParseReply(reply string, expected []string, normalize func(string) string) (map[string]string, int) { +func ParseReply(reply string, expected []string, normalize func(string) string, target *unicode.RangeTable) (map[string]string, ReplyStats) { want := make(map[string]bool, len(expected)) for _, k := range expected { want[k] = true } out := map[string]string{} - bad := 0 + var st ReplyStats for _, ln := range strings.Split(reply, "\n") { if strings.TrimSpace(ln) == "" || strings.HasPrefix(strings.TrimSpace(ln), "#") { continue } f := splitFields(ln) if len(f) < 2 { - bad++ + st.Bad++ continue } key := normalize(f[0]) if !want[key] { - bad++ + st.Bad++ continue } if _, dup := out[key]; dup { @@ -875,7 +888,7 @@ func ParseReply(reply string, expected []string, normalize func(string) string) continue } if !wellFormedLemma(dst) { - bad++ + st.Bad++ continue } // A rendering that IS the source is not a rendering. Models answer this under pressure (a term they @@ -883,12 +896,22 @@ func ParseReply(reply string, expected []string, normalize func(string) string) // it is stamped status:draft, and it reaches the editor as «方源 ⟨проверить⟩» — an untranslated // surface presented as this book's canon. Counted as a bad line, so a reply full of echoes is loud. if normalize(dst) == key { - bad++ + st.Bad++ + continue + } + // Nor is a rendering written in another script — the same argument as the echo check above, at the + // one place a reply becomes bank content. Past here it is indistinguishable from a correct answer. + if OffLanguage(dst, target) { + st.Bad++ + st.OffLanguage++ + if len(st.OffLanguageSamples) < offLanguageSampleCap { + st.OffLanguageSamples = append(st.OffLanguageSamples, f[0]+" → "+dst) + } continue } out[key] = dst } - return out, bad + return out, st } // Batch splits candidates into groups whose rendered size stays under maxRunes, preserving key order. A diff --git a/backend/internal/terminology/terminology_test.go b/backend/internal/terminology/terminology_test.go index dccaa3ba..c9932a3a 100644 --- a/backend/internal/terminology/terminology_test.go +++ b/backend/internal/terminology/terminology_test.go @@ -340,15 +340,15 @@ func TestCanonConflictsFlagsOnlyRealContradictions(t *testing.T) { // stray double space inside a rendering must not truncate it (the splitter is tolerant BY DESIGN, and that // tolerance cuts both ways), and a batch must actually render within the budget it was split for. func TestParseReplyKeepsAWholeRenderingAndBatchBudgetHolds(t *testing.T) { - got, bad := ParseReply("方源\tФан Юань\n花家\tДом Хуа", []string{"方源", "花家"}, func(s string) string { return s }) - if bad != 0 || got["方源"] != "Фан Юань" { - t.Fatalf("a double space inside a rendering must not bank half a name: %v (bad=%d)", got, bad) + got, st := ParseReply("方源\tФан Юань\n花家\tДом Хуа", []string{"方源", "花家"}, func(s string) string { return s }, nil) + if st.Bad != 0 || got["方源"] != "Фан Юань" { + t.Fatalf("a double space inside a rendering must not bank half a name: %v (bad=%d)", got, st.Bad) } // A rendering that IS the source is not a rendering: a model echoing the term back would otherwise be // banked as this book's canon and shown to the editor as «方源 ⟨проверить⟩». - echo, bad := ParseReply("方源\t方源\n花家\tДом Хуа", []string{"方源", "花家"}, func(s string) string { return s }) - if _, banked := echo["方源"]; banked || bad != 1 { - t.Fatalf("an echoed source must be refused and counted: %v (bad=%d)", echo, bad) + echo, st := ParseReply("方源\t方源\n花家\tДом Хуа", []string{"方源", "花家"}, func(s string) string { return s }, nil) + if _, banked := echo["方源"]; banked || st.Bad != 1 { + t.Fatalf("an echoed source must be refused and counted: %v (bad=%d)", echo, st.Bad) } cands := make([]Candidate, 200) for i := range cands { @@ -380,13 +380,13 @@ func TestParseReplyAcceptsOnlyAskedTerms(t *testing.T) { "忘却\t" + NoDst, "мусор", }, "\n") - got, bad := ParseReply(reply, []string{"方源", "花家", "青茅山", "忘却"}, id) + got, st := ParseReply(reply, []string{"方源", "花家", "青茅山", "忘却"}, id, nil) want := map[string]string{"方源": "Фан Юань", "花家": "Дом Хуа", "青茅山": "гора Цинмао", "忘却": ""} if !reflect.DeepEqual(got, want) { t.Fatalf("parse = %#v, want %#v", got, want) } - if bad != 2 { - t.Fatalf("the unusable lines must be COUNTED (silence about them is how a paid call turns into an empty bank), got %d", bad) + if st.Bad != 2 { + t.Fatalf("the unusable lines must be COUNTED (silence about them is how a paid call turns into an empty bank), got %d", st.Bad) } // The declined term is present with an EMPTY rendering — a decision, distinct from "no line at all". if v, ok := got["忘却"]; !ok || v != "" { @@ -398,7 +398,7 @@ func TestParseReplyNormalizesTheKey(t *testing.T) { // The caller passes the engine's source-key normalizer; a reply written in another orthography must // still land on its term. lower := strings.ToLower - got, _ := ParseReply("FANG\tФан", []string{"fang"}, lower) + got, _ := ParseReply("FANG\tФан", []string{"fang"}, lower, nil) if got["fang"] != "Фан" { t.Fatalf("the reply key must be normalized by the caller's function, got %#v", got) } diff --git a/backend/internal/text/enclosure.go b/backend/internal/text/enclosure.go new file mode 100644 index 00000000..cb0a1834 --- /dev/null +++ b/backend/internal/text/enclosure.go @@ -0,0 +1,32 @@ +package text + +import ( + "strings" + "unicode" +) + +// TrimEnclosure strips MATCHED enclosing punctuation from a surface, repeatedly. The ends are classified +// by Unicode category (Ps/Pi open, Pe/Pf close) plus the two ASCII quotes, which are Po and invisible to +// those categories — so no table of any language's quotation marks is held here. +// +// Requiring BOTH ends to pair keeps it from mangling a surface that merely touches punctuation, and an +// empty pair is returned as-is: a key must never be trimmed to nothing. +// +// NOT part of the normalization artifact (norm.go / NormVersion): it is applied by one consumer to one +// class of string (a surface a model wrote), and folding it into NormalizeSourceKey would re-verdict every +// match the bank ever made. TestNormVersionUnmovedByEnclosureTrim pins that separation. +func TrimEnclosure(s string) string { + rs := []rune(s) + for len(rs) >= 3 && isOpeningMark(rs[0]) && isClosingMark(rs[len(rs)-1]) { + rs = rs[1 : len(rs)-1] + } + return strings.TrimSpace(string(rs)) +} + +func isOpeningMark(r rune) bool { + return unicode.Is(unicode.Ps, r) || unicode.Is(unicode.Pi, r) || r == '"' || r == '\'' +} + +func isClosingMark(r rune) bool { + return unicode.Is(unicode.Pe, r) || unicode.Is(unicode.Pf, r) || r == '"' || r == '\'' +} diff --git a/backend/internal/text/enclosure_test.go b/backend/internal/text/enclosure_test.go new file mode 100644 index 00000000..f95eba8a --- /dev/null +++ b/backend/internal/text/enclosure_test.go @@ -0,0 +1,38 @@ +package text + +import "testing" + +func TestTrimEnclosureFoldsPairedMarksInAnyScript(t *testing.T) { + cases := []struct{ in, want string }{ + {"《咏梅》", "咏梅"}, // CJK book-title marks: the case measured in a live draft wave + {"「名前」", "名前"}, // CJK corner brackets + {"«Название»", "Название"}, // guillemets + {"“Title”", "Title"}, // curly quotes + {`"Title"`, "Title"}, // ASCII quotes (category Po — not covered by Ps/Pe) + {"(термин)", "термин"}, + {"《《двойное》》", "двойное"}, // repeats until the ends stop pairing + {"咏梅", "咏梅"}, // nothing to trim + {"O'Brien", "O'Brien"}, // an apostrophe inside a word is not an enclosure + {"咏梅》", "咏梅》"}, // one-sided: left visibly wrong rather than silently repaired + {"「」", "「」"}, // an empty pair is never trimmed to nothing + {"", ""}, + } + for _, c := range cases { + if got := TrimEnclosure(c.in); got != c.want { + t.Errorf("TrimEnclosure(%q) = %q, want %q", c.in, got, c.want) + } + } +} + +// TestNormVersionUnmovedByEnclosureTrim is the axis pin: the trim lives outside the normalization +// artifact, so shipping it moves no snapshot and re-bills no book. If someone folds it into +// NormalizeSourceKey, this literal must be updated deliberately — which is a --resnapshot decision. +func TestNormVersionUnmovedByEnclosureTrim(t *testing.T) { + if NormalizeSourceKey("《咏梅》") == NormalizeSourceKey("咏梅") { + t.Fatal("the normalizer must NOT trim enclosures: that would re-verdict every match the bank ever made") + } + const want = "memnorm-v3-nfkc+apos+stripignorable+trad+kana+lower/nfc+dash+apos+stripignorable+lower+yofold+u15.0.0+xtextv0.38.0" + if got := NormVersion(); len(got) < len(want) || got[:len(want)] != want { + t.Fatalf("the normalization artifact version moved: %q", got) + } +} diff --git a/backend/prompts/zh-ru/terminologist.md b/backend/prompts/zh-ru/terminologist.md index a9812436..4e41a71d 100644 --- a/backend/prompts/zh-ru/terminologist.md +++ b/backend/prompts/zh-ru/terminologist.md @@ -4,6 +4,9 @@ (люди и места — транскрипция; предметы, приёмы, существа — по смыслу): широкая форма опровергнута замером T (P2−P5 = +0.013 при пороге 0.05), а полное снятие правила ломает 月光蛊 и 王婆. Блок ⟦TM-GENRE⟧ снят по D39.47 — жанрового словаря нет как класса. +Строка примера формата добавлена по замеру D39.52: без якоря ⟦TM-CANON⟧ роль срывалась в английский +(22 строки из 120), с примером на целевом письме — 0 из 120. Пример-терм 师父 не входит ни в сид, ни в +якорь: он показывает ПИСЬМО ответа, а не перевод какого-либо термина книги. Файл остаётся ДАННЫМИ: резолвится конвенцией prompts/<пара>/<роль>.md, грузится как шаблон, в снапшот НЕ фолдится и ни один тест не пинит его текст. Смена текста стоит переоплаты только батчей терминолога (их адрес включает байты запроса), Go не трогается. --> @@ -44,6 +47,10 @@ термин_исходникаперевод +Пример строки ответа: + +师父 наставник + Никаких заголовков, нумерации, комментариев и markdown. Термины, которых нет в списке, не добавляй. ---USER--- diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 31b41e19..cadaa221 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,6 +1,6 @@ # Журнал прогресса -> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-07-25, пак-17 закрыт, право по ToS подписано, пак-18 принят — долгов контракта нет, D39.26–D39.31). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1–D39.52); этот файл — ЖУРНАЛ.** +> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-07-25, пак-17 закрыт, право по ToS подписано, пак-18 принят — долгов контракта нет, D39.26–D39.31). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1–D39.53); этот файл — ЖУРНАЛ.** > - **Фазы/курс:** Ф0 ✅ · Ф1-инфра ✅ (D20–D28; golden = инвариант №8) · арка «качество-первым» D26→D38.5 закрыта · **арх-ресет D39 исполнен ЦЕЛИКОМ** (7 слоёв → программа D39.1–D39.10 → паки 11–16, D39.13–D39.24; статус слоёв — шапка-таблица `architecture/09-target-architecture.md`) · пере-прогон rerun2 прочитан (D39.20: операционка живьём, $0-резюм ×3 арма, $6.63<$15; планка ≤2 НЕ пройдена; анти-корреляция гладкость↔верность ×3 → mistral-редактор вон) · эксп ЗАКРЫТ (D39.22), итерация №2 отложена · **курс = разработка бэкенда: пак-17 «канал B вживую» ЗАКРЫТ ЦЕЛИКОМ (дизайн D39.26 · стройка D39.27 · дельта `allow` D39.28, 25.07); вокабуляр = ПАРА `violence`/`sexually-explicit`, остаток — ФАКТЫ ToS в зоне полигона (fail-closed до них)** (D39.25: в движке НЕТ понятия «18+» — generic content-labels × provider-capabilities; лейблы вне хешей · резолв до валидации · один хоп `chain[0]` · гранулярность = книга) → **КУРС АМЕНДИРОВАН ВЛАДЕЛЬЦЕМ 25.07 (D39.33): МАСШТАБ — ПОСЛЕДНИМ, после достройки алгоритмического идеала; доказательство эффективности крупных изменений — МИНИ-ПРОГОНЫ ~10 глав (детерминированные чекеры и скан остатка первыми, судья — только когда от него зависит решение).** **ОЧЕРЕДЬ ИСПРАВЛЕНА ЗАМЕРОМ (D39.35): $0-резюм драфта на стенде УЖЕ МЁРТВ** (снапшот разошёлся по трём осям — cheapgate v3→v4, renderfmt v2→v3, langpack 09974d6→a7d4be2), поэтому срочности «гнать, пока жив» НЕТ, а ниты перестали быть условными: **(1) микро-пак «предпрогонная гигиена» ($0-код): два нита общности + ОБЯЗАТЕЛЬНЫЙ флип `Gates.RegressionGuard.Enabled` (невыполненное обязательство D38.4 п.5) + опц. проекция пере-оплаты в `tmctl status`** · **(2) мини-прогон-замер ~10 глав** (драфт ≈$0.02, редактура ≈$0.08–0.15/арм, судья НЕ зовётся ⇒ один арм ≈$0.10–0.17, два ≈$0.20–0.32; перед прогоном бэкап `rerun2/*.db`, БД на схеме v9 против v10 бинаря) · **(3) параллельно $0 — сид/банк-дельта** (元 · 赤城 · 学堂家老 · 春秋蝉; два терма ждут подписи владельца) · **(4) пак-19 в урезанном скоупе** · (4) далее слой-2 извлечение из тел промтов · ru-target · native-Gemini судья. Затем — сид-дельта (元 · 赤城 · 学堂家老 · 春秋蝉), МАСШТАБ целой книги (7,78 млн символов, ~2284 раздела) и пилот Ф2.5. Хроника треков A/B, exp15/16 и стройки паков — архивы ниже + D-лог. > - **Стек:** draft deepseek-v4-flash (thinking ON) → **editor deepseek-v4-pro БИЛИНГВ ИНТЕРИМ (D39.22; glm-5 резерв, mistral вон D39.20)**, промпт v3-discourse (P1a+чэнъюй+few_shot-тумблер) → судья gemini-3.1-pro-preview; канал B Mistral+grok; 7 ключей; ~$0.85/ранобэ (D30.4). > - **ОТКРЫТЫЕ ПЕТЛИ (норма 25.07, перепись 180 инцидентов: класс NEVER_CLOSED больше не копим — каждая петля обязана получить диспозицию решено/отложено-с-записью/отклонено; ведёт оркестратор).** *Владелец:* ~~вокабуляр content-лейблов~~ **РЕШЕНО 25.07: ПАРА `violence`/`sexually-explicit`** (D39.27 п.5 — сохраняет D14 п.1 и dspro-редактора на violence) ⇒ следствие в работе: assert-only `action` (дельта пака-17) · ~~Q2 редактор под лейблом~~ **ОТЛОЖЕН С ЗАПИСЬЮ** (D39.27 п.7: при паре связывает только `sexually-explicit`-книги, которых нет; возвращается вместе с предусловием D22.7 перед первой explicit-книгой; кандидаты — grok-4.3 интерим, glm-5 только после сверки ToS Z.AI по первоисточнику, mistral отвергнут D39.20) · ~~ToS-факты + три решения по ним~~ **ЗАКРЫТЫ 25.07 (D39.29 факты + D39.30 подписи владельца):** лейбл `violence` НЕ заводится, интерпретация «нейтральное изображение ≠ пропаганда» зафиксирована письменно · риск Z.AI (плоский запрет «violent content»; экспозиция состоялась в rerun2 через glm-арм) ПРИНЯТ сознательно с условиями пересмотра · две строки `accepts_labels: [sexually-explicit]` (`xai`, `mistral`) ПОДПИСАНЫ и ПРИМЕНЕНЫ в `models.yaml`. **Остаток ToS-вопросов ЗАКРЫТ владельцем 25.07 (D39.32):** local = «политик нет, переводим всё» ⇒ строка применена · Gemini-оговорка читается СУЖЕНИЕМ ⇒ вердикт `FORBIDDEN`→`UNCLEAR`, но по доктрине это НЕ разрешение: строку Gemini не получает, **ждёт одного слова, если владелец хочет подписать интерпретацию как разрешение** · причину пустоты в конфиг НЕ выносим (живёт в quirks-доке) · **ре-чек политик стал ПРАВИЛОМ** (триггеры: Google ToS 30.07.2026 · квартал 25.10.2026 · любая правка `accepts_labels`) · дата аккаунта OpenAI — открыта, приоритет низкий (OpenAI вне цепочки). **Продуктовое (Ф3):** Gemini API Additional ToS запрещает клиентов, «likely to be accessed by individuals under 18» — обязательство на конечный продукт, независимо от лейблов · **промпт-слой под лейблом (НОВОЕ, вскрыто вопросом владельца): лейбл сегодня НЕ доезжает до промпта** (чистые данные маршрутизации + load-time гарантия); нужно ли, чтобы переводчик/редактор ЗНАЛИ о свойстве контента — отдельное решение промпт-слоя · ~~мини-каноны сид-дельты~~ ЗАКРЫТО 26.07: все четыре пункта решены, правок сида ноль; книжные канон-решения живут С КНИГОЙ (`books/gu-zhenren/CANON-NOTES.md`), НЕ в D-логе — урок владельца: движок не строится вокруг одной книги · ~~включение платной петли ремонта~~ **РЕШЕНО 26.07 (D39.38): НЕ включается** — замер 0 в её классах; вернуться при расширении классов и ненулевом остатке; **уточнение D39.39: «0» = ноль в охвате детекторов с измеренным теперь recall (латиница 0.50 боевой, битые формы 0.10) — решение в силе (петля видит теми же детекторами), но читать как «финал чист» нельзя** · **вопросы разметки Р1–Р4 (НОВОЕ, D39.39, пакет-6 §5, 21 спорная строка):** гипербола 千万 (довод: тот же автор в гл.2 даёт 数十万 о том же деянии) · что есть дефект в классе 成 — форма или значение (от ответа precision K5c 1.000 или 0.167) · сокращённая форма термина («гу» при dst «гу-тварь») · принципиально неразрешимая офлайн речь/мысль (если да — потолок recall K4b < 1.0 by construction, вписать в контракт правила) · ~~место фикс-пака «чекеры» в очереди~~ **РЕШЕНО 26.07 (D39.40): это ПАК-21, а перед ним мини-прогон ~10 глав на полном бэкенде (после лендинга 20 и 19)**; ответы Р1–Р4 нужны к дизайну пака-21 · ja→ru-реплика §B5 (тайминг) · старые Ф2.5-блокеры (билингв-якорь — D25 п.9 Q1 · контаминация корпуса — D27 п.4 · судья-дублёр D22.6 · планка запуска · publishable/waiver — D25 п.1 · FN-bound L3 — D25 п.4 · юр-пакет · провенанс 12-*-доков · xAI-ключ off перед продом D27 · residual-тачпойнты exp16, не влитые в сид-дельту: мини-голд алиасов · precision@30, `books/gu-zhenren/exp16/`). *Оркестратор:* ~~ратификация дизайна пака-16~~ РЕШЕНО D39.24 (25.07) · фантом-гард D-ссылок в чек-листе лендинга (действует) · ~~doc-sync pass~~ **ИСПОЛНЕН 25.07** (запись ниже: оба puml перерисованы под пост-пак-16 · баннеры на 01/02/03/04/06/07/08/09 · ресёрч-свип 18/18c/19/20/21/22 · README/POLYGON_PACKAGE4 освежены; residual — пинги полигону в его зоне) · ~~приёмка пака-17 фаза 1~~ **РЕШЕНО D39.26 (25.07: принято в редакции §14, три добора приёмки, фаза 2 размечена)** · **`escalation_model` = ОТВЕТИВШАЯ модель (НОВОЕ, вынесено из пака-17): вердикт-изменение** — golden пинит `esc_model` на ОТКАЗАВШЕМ хопе, маскировка не скрывает ⇒ отдельная ратификация с санкционированным пере-капчером · **`--accept-rebill[=usd]` с порогом `min($0.50, 5%×ProjectedBookUSD)` РАТИФИЦИРОВАН D20.2-Q2, но в коде/CLI ОТСУТСТВУЕТ (НОВОЕ)** — живёт только в спеке `backend/docs/D15.2-*.md`; `--resnapshot` пере-пиннит без суммы · **D22.7 (пер-чанковый L3-скрин) — предусловие первой erotica-книги в проде НЕ снято паком-17 (НОВОЕ, держать отдельным гейтом)** · фантом-гард D-ссылок в чек-листе лендинга (действует). *Бэкенд:* **ХВОСТЫ МИНИ-ПРОГОНА (D39.37, 26.07, по убыванию веса):** ~~(1) размеченный набор для чекеров~~ **ЗАКРЫТ 26.07 (полигон-пакет-6, D39.39)** ⇒ породил вход нового фикс-пака «чекеры»: 12 дефектов с file:line (супрессор K5c гасит юнит целиком · атрибуция K4b (23 безатрибутивных + 12 с `!?…`) · `inner_marker` на тире-строки · предохранитель K5a отсекает все реальные магнитуды · заглавные/гомоглифы K2 · 两 в `cheng_re` · U+0301 рвёт `TokenizeCyrillic` · мн.число в `decl.forms`-тулинге · однознаковые ключи пост-чека) — приёмка против набора за $0; **место РЕШЕНО (D39.40): ПАК-21, после мини-прогона на полном бэкенде; + переезд `translitInterjections` в данные (улов владельца)** · (2) **тест-пин джойна WHAT↔WHICH** (мутация оркестратора выжила — фикс-лист №1 следующего касания) · ~~(3) покрытие WHAT↔WHICH + флаговый майнинг-стоп~~ **ПАК-20 ЗАКРЫТ ЦЕЛИКОМ 26.07 (фаза 1 D39.41 · решения D39.42 · фаза 2 ПРИНЯТА И ЗАЛЕНДЕНА D39.45): терминолог + `--verify-bank` (дефолт=авто) + авто-провод с пометкой + точечная ре-редактура с $0-пере-пином + банкнота отдельным файлом; живьём на 10 главах, $0.0476, деньги сходятся тремя путями; заделы серии/импорта — контрактами.** Открытое по паку: `feed_cap` (рекомендация: малой дельтой) · жанровые метки словаря · подпись словаря после фазы B полигона · resnapshot стендовых книг с auto-строками; хвосты: `AttachKWIC` квадратичен · смета молчит о пере-покупке роли на подписи · S12 частичен · zh-worst-case в `bankTokenBudget` (утечка §0 признана) · gofmt `llm.go` (до-паковый) · **вариативность банкноты 37↔14 (n=2) → полигону в фазу B** · (4) `dialogue_dash` мерить только на невиданном тексте (подогнан под 蛊真人) · (5) генеральность нарезки: кана/ханьцзи один класс ⇒ ja `est_out` ~1.7× завышен МОЛЧА; `EstimateTokens` недосчитывает кириллицу (резервации занижены) · (6) golden второй фикстурой С langpack · (7) `prompt_override` — честная формулировка гарантии слоя-2 · (8) майнер/банк не-CJK (едет с ja→ru) · (9) `request_log` скоуп/ретеншен + `first_flag_reason` третье представление истории · (10) флор-16000 без модели · (11) `max_tokens`-упоры вернутся на en · ~~пак-17 (фазы 1–2 + дельта)~~ **ЗАКРЫТ ЦЕЛИКОМ 25.07 (D39.26/27/28)** · ~~ПАК-18 «долги контракта»~~ **ПРИНЯТ И ЗАЛЕНДЕН 25.07 (D39.31) — долгов контракта не осталось**; выдавался (`BACKEND_PACK18_DEBTS_SESSION_PROMPT.md`: `--accept-rebill` на снапшотной гранулярности — `guard_hash` в коде нет и не строим · `escalation_model` = авторитетная модель с САНКЦИОНИРОВАННЫМ пере-капчером golden под этот дифф · два нита общности условно-байт-нейтрально) · **ru-target долг (слой 7, `isRuTarget`×11 прод-сайтов · `TokenizeCyrillic`×6 · ключ «ru» в санитайзере) — ЖДЁТ СВОЕГО ДИЗАЙН-ПАКА** (связывает только не-русский таргет, по текущей карте zh/ja/en→ru не срочен) · **ПАК-19 «голос и состояние» ВЫДАН 25.07** (`BACKEND_PACK19_VOICE_STATE_SESSION_PROMPT.md`, двухфазный; ниты общности едут ЕГО лендингом — D39.31 п.5) · **проекция пере-оплаты в `tmctl status` (НОВОЕ, из отчёта пака-18): спека §9 предлагает заменить булев `ConfigDrift` числом «N чанков, ~$X» — дёшево, `omitempty`, не сделано сознательно** · **не-долги, следующие несущие:** масштаб целой книги (волны/каденс/потолки/ETA на сотнях глав — гоняли максимум 5) · Ф2-механизмы (голос/состояние D21 · native-Gemini судья) · извлечение дискурс-норм/few-shot из ТЕЛ промтов (слой 2, форсинг = ja→ru) · ~~пак-16 ф.2~~ ЗАЛЕНДЕНА 25.07 · per-class исходы ремонта — схемное решение (класс не durable), принимается ВМЕСТЕ с включением петли · ниты общности (владелец 25.07) — **ОТЛОЖЕНЫ ПО ЗАМЕРУ (D39.31 п.5): едут на ближайший ПОЛНЫЙ `--resnapshot`**; для Detail-строк байт-нейтральной формы не существует определительно (дефолт шаблона обязан рендерить текущую строку). Прежняя формулировка: только в байт-нейтральной форме с доказательством исполнением (иначе `CheapGateVersion`/`pack.Version()` двигают снапшот стендовых книг и убивают $0-резюм драфта под замер остатка пака-16): Detail-строки чекеров → шаблоны/цитаты из данных (статический 时辰-текст в generic-файле соврёт второй паре) · множитель ×2 `lintTimeUnits` (`checkers.go:174`) → ключ данных. *Полигон:* ~~пакет-6 (размеченный набор чекеров)~~ **ОТРАБОТАН И ПРИНЯТ 26.07 (D39.39): 3015 позиций, 6 прогонов, $0; metrics.json воспроизведён побайтно; набор на стенде `books/gu-zhenren/labels/` = постоянный измерительный стенд чекеров; ~~ПАКЕТ-7 (v2, ресёрч-программа)~~ **ЗАКРЫТ ЦЕЛИКОМ 26.07 (A: D39.43 · Z-решения: D39.44 · B+B′: D39.46; $0.45043 из коридора $2.60): ратифицировано «контексты — главный рычаг терминолога»; судья = только катастроф-экран с декоем; словарь инертен до расширения эмиссии на term; три процессных правила — в нормы промтов; ~~на владельце подписи Z-B1–B4~~ **АМЕНДИРОВАНО D39.47: жанровый словарь ОТМЕНЁН КАК КЛАСС (решение о переводе = подпись владельца на банк КНИГИ; Z-B1/B2/B6 сняты, Z-B4 сужен, Z-B3 остаётся — промпт уже в узкой форме); дельта размотки → бэкендерам, главный вопрос покрытия = расширение эмиссии на term + feed_cap (ждёт санкции)**; амендмент D39.42 п.1 (терминолог на полном банке = единицы долларов) и Q4→Р3 (сокращённая форма = поле записи, вход пака-21) — в силе; полигон-очередь ПУСТА, кандидаты: вариативность банкноты 37↔14 · ёфикатор/санитайзер-разметка · dialogue_dash на невиданном (оставшиеся кандидаты: ёфикатор/санитайзер-классы не размечены — §6.5 отчёта пакета-6; `dialogue_dash` на невиданном тексте) · ~~пакет 5 (факты ToS + 8 пингов)~~ **ОТРАБОТАН ЦЕЛИКОМ И ПРИНЯТ 25.07 (D39.29); все восемь пингов закрыты.** Исторический список закрытых пингов: (1) `00-provider-quirks.md:13,73` — катовер deepseek-chat 24.07 ПРОШЁЛ, пост-катовер факт (жив ли алиас, цены) не внесён — live-проба `/models` + вендор-дока; (2) `00-provider-quirks.md:69` — оговорка «mistral-editor-арм до guard» superseded D39.20 (mistral вон из редакторов; rate-limit-цифры оставить — нужны переводчику канала B пака-17); (3) `09-pilot-protocol.md:3` — ⚠-указатель застрял на D31/glm-5: продлить до пост-D39.22 (dspro-интерим · планка ≤2 не пройдена rerun2 · авто-QA слепыми метками D39.20 · стайл-каноны D39.21), тело не трогать; (4) `15-segmentation-empirics.md:6-7` — шапка до сих пор «НИ ОДНОГО платного вызова», а эксп залендён REV.2 (D39.7/D39.8, $12.79) — перевернуть статус-блок; (5) `16-bank-mining.md:3` — дописать финал «ИСПОЛНЕН И ЗАЛЕНДЁН (D39.10); gender-тачпойнт §7 закрыт (D39.19); продолжение — Go-майнер парити EXACT»; (6) `experiments/README.md` — индекс без строк exp15/16 (сверка застряла на D38.2); (7) `eval/README.md` — не тронут с 19.07 (пре-rerun2): освежить статус под пост-D39.22 (эксп закрыт · судейский риг-стандарт D39.7 · экстракция только `tmctl export`). diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index 64a3fcc9..c9155d61 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -1162,3 +1162,9 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу ## D39.52 — Замеры D39.51 закрыты ($0.00960; пакет-8 итого $0.12273): (а) строка примера формата ЧИНИТ срыв языка (22/120 → 0/120) — ИДЁТ В ПРОМПТ РОЛИ данными вместе со стройкой пп.1–7; (б) P1 consistency НЕ разделяет (вырожденность 0.740, критерий провален, частота — ноль) — права на порядок подачи нет, пере-замер на холодном прогоне $0 (26.07, оркестратор №8). ✅ Приёмка воспроизведением из сырья: (а) 0/120 не-целевых строк в арме с примером — независимый пересчёт; фактор один (дифф запросов — только три строки примера; пример-терм 师父 не входит ни в выборку, ни в якорь, ни в сид); мощность 0.104 названа до прогона, удвоение повторов не берём (страховка = экран + OffLanguage живьём). (б) ре-ран скорера: 54/73 вырождено, критерий ≥80/≥60 не выполнен нигде, частотный арм — независимое подтверждение §5.1 на другом наборе и другой разметке (три слепых модельных разметчика, согласие 0.973–0.986; оговорка «разметчики — модели» названа). **Хвост в пак-21: утечка алфавита** — «甲等 → категория A» (7 строк): языковой предикат пропускает (кириллица есть), подписанные dst латиницы не содержат вовсе; кандидаты — банк-линт латиницы в dst или строгая форма предиката (§2.2-Б дизайна). **Вопросы холодного мини-прогона пополнены:** + пере-замер P1 на популяции без цензуры ($0 из чекпойнтов). Стройка бэкенда: пп.1–7 §8 + п.8 «строка примера в промпте роли» — санкционирована, ждёт релея. + +## D39.53 — Стройка «дисциплина банкноты + языковой экран» ПРИНЯТА И ЗАЛЕНДЕНА (пп.1–8 D39.51/52, $0, +454/−100): экран на выходе роли и черновой стороне · кавычки в свёртке · агрегация из чекпойнтов с голосами по ЧАНКАМ · kwic_width в пар-данные (zh-ru файл не шипуется) · строка примера побайтно та, что мерил полигон (26.07, оркестратор №8). ✅ + +**Приёмка:** M-Я1/M-Я2 красные + моя мутация (дедуп голосов) красная · предикат по живому сырью: 27 латинских строк §3.4 побайтно + **два улова сверх слепоты старой метрики** (китайские пересказы 第四代族长, 长老 — эхо-гейт и wellFormedLemma их пропускали) · регресс 16 живых строк чист, смешанная `Фан Юань (Fang Yuan)` проходит (экран судит язык, не написание) · пины общности/NormVersion/инварианта агрегации зелёные · vet/gofmt/golden чисты. Отчёт: `docs/archive/reports/PACK20_BANKNOTE_BUILD_2026-07-26.md` (ревью-шапка). Осей нет: снапшоты не двигаются; редакторская волна перекупается один раз только у книги, где банк реально изменится (иноязычная строка/кавычечные дубли). + +**Находка стройки, решение задефолчено:** `hasBankSrcHan` — канал банкноты МЁРТВ для не-CJK исходника (каждая строка отвергается с parse_fail; утечка языковой семьи в общий слой, существующая, не этой стройки). Дефолт: чинить пар-слепым правилом «исходная строка непуста и встречается в тексте книги» (заодно отсекает выдуманные строки) **на холодном мини-прогоне** — версия парсера фолдится в снапшот, а холодная база платит ноль. **Холодный мини-прогон теперь несёт:** свои пять вопросов (D39.51) + пере-замер P1 + веса §C2-3 + allow_short 蛊/转 + фикс парсера банкноты + счёт «утечки алфавита» (категория A) живьём. Очередь: пак-19 → холодный мини-прогон → пак-21. diff --git a/docs/archive/reports/PACK20_BANKNOTE_BUILD_2026-07-26.md b/docs/archive/reports/PACK20_BANKNOTE_BUILD_2026-07-26.md new file mode 100644 index 00000000..4640dfbe --- /dev/null +++ b/docs/archive/reports/PACK20_BANKNOTE_BUILD_2026-07-26.md @@ -0,0 +1,225 @@ +# Пак-20, продолжение: СТРОЙКА дисциплины банкноты и языкового экрана + +**База:** D39.51 (дизайн ратифицирован, пп.1–7 санкционированы) + D39.52 (п.8 — строка примера в промпте). +**Зона:** `backend/`. **Деньги сессии: $0** — ни одного платного вызова, вся приёмка на сохранённом сырье. +**Статус:** построено и проверено. Сессия НЕ коммитит, лендинг — оркестратора. + +> **Ревью-шапка (оркестратор №8, 26.07): ПРИНЯТО И ЗАЛЕНДЕНО, D39.53.** Приёмка исполнением: vet/gofmt +> чисты (llm.go — до-паковый), свежие прогоны terminology/text `-race -count=1` и golden зелёные; моя +> адверсариальная мутация вне списка сессии (дедуп голосов по чанкам снят) — КРАСНАЯ, восстановление +> побайтно. Прогон предиката по живому сырью — 27 латинских строк совпали побайтно + два честных улова +> сверх метрики полигона (китайские пересказы 第四代族长/长老) — принято как улучшение, не расхождение. +> §5 (парсер банкноты мёртв для не-CJK исходника, parse_fail на каждой строке) — дефолт решения: +> чинить пар-слепым правилом «строка непуста и встречается в тексте книги» НА холодном мини-прогоне +> (версия парсера фолдится → перекупка; холодная база платит ноль). Вето владельца — одной строкой. + +--- + +## §1 Что построено + +| № | Пункт | Где | +|---|---|---| +| 1 | Языковой экран на выходе роли | `terminology/script.go` (`OffLanguage`, `ScriptByName`) + отказ строки в `ParseReply` там же, где эхо-гейт | +| 2 | Счётчик `OffLanguage` + громкий лог батча | `terminology.ReplyStats`, `terminologyResult.OffLanguage`, warn в `runTerminologist` | +| 3 | Языковой фильтр черновой стороны | `pipeline/banknote.go` — `bankFold`, счётчик в стоп-логе | +| 4 | Нормализация обрамляющих кавычек | `text/enclosure.go` (`TrimEnclosure`), применяется в свёртке | +| 5 | Агрегация из чекпойнтов | `store.RoleResponsesForBook` + `Runner.bankObservedForBook` | +| 6 | `kwic_width` в пар-данные | `lang.TerminologySizing` + необязательный `terminology.txt`; порядок резолвинга в `terminologyOpts` | +| 7 | Хвост доки про жанровый словарь | `config.TerminologyGate` | +| 8 | Строка примера формата | `prompts/zh-ru/terminologist.md` — ровно три строки, которые мерил полигон | + +**Объявление письма цели:** `gates.terminology.target_script` (имя скрипта Unicode). Обязателен при +`enabled: true` — гейт, который не может отработать, отвергается на ЗАГРУЗКЕ, той же идиомой, что бюджет +и путь промпта. Резолвится через `unicode.Scripts`, поэтому набор допустимых значений — Unicode'овский, а +не список языков, который движку пришлось бы вести. + +**Свёртка банкноты переписана в один накопитель.** Раньше это были две функции над строками телеметрии; +теперь `bankFold` — единственное место, где предложение становится уликой, и потому единственное место, +где применяются все три дисциплины. Побочно удалена `bankProposalsByKey` — она дублировала +`proposalsFromObserved` и оставалась только ради теста. + +**Голоса считаются по ЧАНКАМ, а не по строкам хранилища.** Это то, что делает объединение безопасным: +чанк, перекупленный дважды, голосует один раз, поэтому union не раздувает частотный фактор ранжирования. + +--- + +## §2 Приёмка + +### §2.1 Мутации + +| Мутация | Что сделано | Результат | +|---|---|---| +| **M-Я1** | экран снят из `ParseReply` целиком | **КРАСНАЯ**: `TestParseReplyRefusesForeignLanguageRenderings`, `TestOffLanguageSamplesAreBounded` | +| **M-Я2** | предикат ослаблен порогом «строка короче 3 рун — не проверять» | **КРАСНАЯ**: обе плюс `TestOffLanguageSeparatesForeignRenderingsFromTargetOnes` (ловится на `is`) | + +Оба раза файл восстановлен из копии, сьюта после восстановления зелёная. + +### §2.2 Прогон предиката по ЖИВОМУ сырью пакета-8 + +126 сохранённых вызовов замера 3 (сетка + арм без якоря), **827 принятых строк ответа**. Разбор — боевым +`ParseReply` без объявленного письма (чтобы увидеть все принятые строки), затем предикат. + +| Арм | Помечено | из них латиница | из них ханьцзы | +|---|---|---|---| +| 0×0 · 3×40 · 3×120 · 5×80 · 8×40 | **0** | 0 | 0 | +| 8×120 | 7 | **5** | 2 | +| без якоря | 22 | **22** | 0 | +| **Итого** | **29** | **27** | **2** | + +**Критерий «ровно 27 строк §3.4 и ни одной сверх» выполнен на своих условиях: 27 латинских — те самые +27** (5 в 8×120 + 22 в арме без якоря, побайтно совпадает с таблицей полигона), и ни одного срабатывания +в четырёх армах, где отчёт даёт 0/40. + +**Расхождение 29 против 27 объясняю, а не сглаживаю.** Полигон считал «латиница без единой кириллицы»; +предикат считает «есть буквы, и ни одна не в письме цели». Ханьцзы — тоже буквы, поэтому предикат видит +два ответа, которых метрика полигона не могла увидеть по построению: + +``` +四代族长 → 第四代族长 家老 → 长老 +``` + +Модель ответила китайским пересказом вместо русского перевода. Эхо-гейт их пропускает (ответ не равен +исходнику побайтно), `wellFormedLemma` пропускает. Это не ложные срабатывания — это два настоящих улова +в классе, к которому исходный замер был слеп. Ошибок в другую сторону нет: 798 корректных строк не +задеты. + +**Независимые подтверждения на том же сырье:** + +- **арм со строкой примера (замер D39.51):** 9 вызовов, 120 принятых строк, **0 помечено** — боевой код + воспроизводит объявленные полигоном 0/120; +- **проба хвоста (замер 2):** 21 вызов, 312 принятых строк, **18 помечено**, все латиница — включая + названный в отчёте `是为 → is` и пиньинь-передачи `方源 → Fangyuan`, `方正 → Fangzheng`, которые для + zh→ru переводом на целевой язык не являются. + +### §2.3 Регресс, общность, инварианты + +| Проверка | Итог | +|---|---| +| 16 живых целевых строк (подписанные владельцем + пограничные: 2 буквы, только цифры, в кавычках, двойной пробел) | зелёные, ни одного ложного срабатывания | +| Смешанная строка `Фан Юань (Fang Yuan)` | проходит — экран судит язык, а не написание | +| **Пин общности** | при латинской цели вердикты переворачиваются; при ханьской работают обе; `ScriptByName` строгий, опечатка падает на загрузке, а не выключает экран молча | +| Пин отсутствия объявления | без письма экран инертен — состояние, которое конфиг отвергает для включённого гейта, зафиксировано тестом, а не оставлено сюрпризом | +| **Инвариант агрегации** | `TestBankUnionRecoversASupersededSampling`: второй ответ на тот же чанк пишется через обычный денежный путь, объединение достаёт затёртую выборку, счётчик чанков остаётся 1 | +| Байт-стабильность карты при резюме | держит существующий `TestAutoWireIsDeterministicAndDoesNotMoveTheDraftWave` (авто-банк побайтно равен, черновая волна не двигается, резюм $0) | +| Детерминизм свёртки | 8 прогонов подряд — порядок ключей и рендерингов идентичен | +| Кавычки | `《咏梅》` и `咏梅` сходятся в один кандидат с двумя вариантами; односторонний огрызок не «чинится» молча; пустая пара не срезается в ничто | +| `NormVersion` не сдвинут | пин-страж: нормализатор кавычки НЕ снимает, версия артефакта прежняя | +| Пар-данные | пак zh-ru без файла хешируется как раньше; файл добавили — версия сдвинулась; пустой/битый файл падает на загрузке | +| Конфиг | пропущенное/неизвестное/не в том регистре имя письма — громкая ошибка загрузки; `Hiragana` объявляется так же, как `Cyrillic` | + +### §2.4 Сьюта + +`go test ./...` зелёная · `-race` зелёная на pipeline (67 c), terminology, lang, text, store, config · +`TestGoldenDeterminism` PASS · `go vet` чисто · `gofmt -l` — только предсуществующий `internal/llm/llm.go`. +Диффстат: 20 файлов, +454 / −100. + +--- + +## §3 Оси + +| Изменение | Снапшот | Кто перекупается | +|---|---|---| +| Языковой экран, счётчик, фильтр свёртки, кавычки, агрегация | не двигается | никто напрямую. У книги, где в банке лежала иноязычная строка или пара кавычечных дублей, содержимое банка изменится → обогащённая версия сдвинется → редакторская волна оплатится ОДИН раз. Книга без таких строк — байт-в-байт прежняя | +| `target_script` в конфиге | не двигается (гейт не фолдится) | никто | +| `kwic_width` в пар-данные | не двигается: файл для zh-ru не шипуется, `Version()` побайтно прежний (проверено тестом) | никто | +| Строка примера в промпте | волны не двигаются | только батчи терминолога (их адрес включает байты запроса) | +| Агрегация | — | у книги с ОДНОЙ покупкой черновиков поведение прежнее; у книги, где черновики покупались повторно, карта подписи вырастет один раз | + +Прод после стройки не дорожает ни на цент: всё детерминированное и $0. + +--- + +### §2.5 Синк с полигоном: промпт побайтно тот, что мерили + +Строка примера взята не по описанию, а сверена с артефактом. Скопированный полигоном промпт лежит в его +скретчпаде (`d51/prompt/terminologist-example.md`); диффом от «Ты —» до конца файла **тело промпта, +который дал 0 из 120, и тело промпта в репозитории совпадают побайтно** (различается только шапка- +комментарий, которую модель не видит). Табуляция в строке примера — настоящая, проверено `cat -A`. + +Прочие пункты отчётов полигона сверены и разведены по местам: + +| Находка полигона | Где в стройке | +|---|---| +| Срыв языка без якоря (22/120) | п.1–3, приёмка §2.2 | +| `是为 → is` в пробе хвоста | ловится, §2.2 | +| Кавычки `《咏梅》` против `咏梅` | п.4 | +| Невоспроизводимость канала между прогонами | п.5 | +| `kwic_width` в рунах — утечка пары | п.6 | +| P1 consistency не разделяет | НЕ строил ничего: права на порядок подачи метрика не заработала | +| Утечка алфавита («категория A» с латинской буквой) | НЕ строил: отнесено в пак-21. Экран её не ловит по построению (кириллица в строке есть) — и стройка этого не меняет ни в одну сторону. Замечу только, что арм с примером дал таких строк больше (7 против 3), так что в холодном прогоне их стоит посчитать | +| `allow_short`, холодный старт, второй детектор | не эта стройка | + +--- + +## §3.1 Селф-ревью по диффу (после стройки, до отчёта) + +Прошёл диффом по всем файлам; три вещи нашёл и починил, одну назвал. + +1. **Висячая ссылка в доке.** Комментарий `bankObservedByKey` ссылался на `bankProposalsByKey` — функцию, + которую я в этой же стройке удалил. Исправлено на реального потребителя. +2. **Объединение читало ВСЮ историю ответов книги в память.** `RoleResponsesForBook` тянула каждый + сохранённый черновик; на десяти главах это мегабайты, на тысяче глав с повторами — сотни. Добавил + необязательный фильтр объёма по подстроке: маркер канала передаётся ПАРАМЕТРОМ запроса, поэтому в SQL + не появилось второй копии константы, а читаются только те ответы, которые в принципе могут что-то дать. + Семантику это не трогает — разбор по-прежнему один, в Go. +3. **Лишний параметр.** `loadTargetScript` принимал логгер, хотя `r.Log` к этому моменту уже установлен. +4. **Назвал, менять не стал:** комментарий у `packAlgoVersion` требует бампать тег «на новый файл», а я + тег не двигал. Расписал правило точнее прямо в коде: бампается тег, когда меняется ЧТЕНИЕ уже + существующих байт; необязательный файл, которого нет ни у одной пары, ничего не читает иначе и потому + версию двигать не должен — иначе перепокупаются обе волны каждой книги ради механизма, которым никто не + пользуется. Добавлен пин на сам тег, чтобы будущий бамп был решением, а не побочным эффектом. + +**Изменение поведения, которое надо назвать явно.** Голоса теперь считаются по чанкам, поэтому +повторённая В ОДНОМ блоке строка `src→dst` даёт один голос, а не два, как раньше. Это исправление (один +чанк — одно свидетельство, а не «модель дважды написала одно и то же»), но на уже накопленных данных оно +может слегка сдвинуть ранжирование вариантов §C2-3. Осей это не двигает: ранжирование — вход роли и +таблицы стопа, не байты запроса волн. + +--- + +## §4 Отклонения и решения, принятые по ходу + +1. **Кавычки нормализуются ТОЛЬКО в свёртке, запись сырой строки не тронута.** В дизайне допускались оба + места. Одно место — одно определение; сохранённая строка остаётся записью того, что модель написала на + самом деле, а сведение ключей — свойство join'а, а не наблюдения. +2. **Запрос чекпойнтов не фильтрует производные $0-строки в SQL.** Они несут не-провайдерский + `finish_reason`, и обычное правило «доверяем только завершённой генерации» отсекает их само. Второй + копии этого правила в SQL быть не должно. +3. **Предложения отвергнутых попыток входят в объединение** — как и объявлено в дизайне: черновик, + забракованный за дефект ТЕКСТА, не делает объявленные в нём термины неправдой, а подписи всё равно нет. +4. **Тестовые фикстуры терминолога теперь объявляют письмо.** Это не «правка под тест»: гейт без + объявления перестал грузиться — ровно то поведение, которое строилось. + +--- + +## §5 Найдено при стройке, НЕ починено (нужно решение) + +**Канал банкноты мёртв для любого не-CJK исходника.** `parseBanknote` отвергает строку, в исходной части +которой нет ханьского иероглифа (`hasBankSrcHan`, диапазон U+3400–U+9FFF). Проверено исполнением: + +``` +исходник не-CJK: Silversaint→Серебряный святой, San Michon→Сан-Мишон → принято 0, parse_fail=true +исходник CJK: 方源→Фан Юань → принято 1, parse_fail=false +``` + +То есть на английской или любой другой не-иероглифической паре канал, который несёт 5/6 кандидатов банка, +не просто молчит — он отвергает каждую строку и поднимает флаг ошибки разбора. Это прямая утечка +конкретной языковой семьи в общий слой, и она из существующего кода, не из этой стройки. + +Чинить в этом паке не стал: правило живёт в ПАРСЕРЕ, чья версия фолдится в снапшот, поэтому правка = +`--resnapshot` и перепокупка черновой волны каждой книги с включённым каналом. Это отдельное решение об +оси. Содержательно правильное правило, по-моему, не «в исходнике есть иероглиф», а «исходная строка +непуста и встречается в тексте книги» — проверка, которая пар-слепа по построению и вдобавок отсекает +выдуманные строки, чего нынешняя не делает. + +--- + +## §6 Что на владельце + +1. **Лендинг стройки** (оркестратора) — пп.1–8 готовы, приёмка §2 исполнена. +2. **Решение по §5** — чинить ли пар-зависимость парсера банкноты и когда: правка дешёвая, ось дорогая + (перепокупка черновой волны). Естественный момент — холодный мини-прогон, где книга покупается заново. +3. Ранее отложенное и не изменившееся: судья-с-декоем ждёт холодного старта; `allow_short` для `蛊`/`转` — + данными на холодном прогоне; пере-замер P1 — оттуда же, $0 из чекпойнтов. + +**СТОП.** Сессия не коммитила.