diff --git a/backend/internal/config/pipeline.go b/backend/internal/config/pipeline.go index adff2be9..e69f71e5 100644 --- a/backend/internal/config/pipeline.go +++ b/backend/internal/config/pipeline.go @@ -320,6 +320,29 @@ type TerminologyGate struct { // PromptPath is the RESOLVED role prompt (`//terminologist.md`), filled by // LoadPipeline by the ordinary role convention. Not a config key. PromptPath string `yaml:"-"` + // ClassifyTypes turns on the §2 type-classifier PHASE: before the terminologist renders, a focused pass + // over the same batches re-derives each candidate's type (the draft heuristic is wrong 12–22%). The + // corrected type routes conformance, primes the wire block and is the banked type, so a realia surface + // mistyped as a name is no longer FORCED to a transliteration. Off → the heuristic type is used exactly as + // before, byte-identically. The phase reuses the batch sizing and target-script above. + ClassifyTypes bool `yaml:"classify_types"` + // ClassifyModel is the classifier phase's model; empty → Model. Classification is cheaper than a render, + // so an operator may point it at a smaller model. + ClassifyModel string `yaml:"classify_model"` + // ClassifyBudgetUSD is the classifier phase's OWN book-wide ceiling, separate from BudgetUSD so a classify + // overrun cannot starve the render phase and the two costs stay attributable apart. >0 required when on. + ClassifyBudgetUSD float64 `yaml:"classify_budget_usd"` + // ClassifyPromptPath is the RESOLVED classifier prompt (`//classifier.md`), filled by + // LoadPipeline. Not a config key. + ClassifyPromptPath string `yaml:"-"` +} + +// ClassifierModel resolves the classifier phase's model — its own if set, else the render model. +func (g TerminologyGate) ClassifierModel() string { + if g.ClassifyModel != "" { + return g.ClassifyModel + } + return g.Model } // RepairGate controls the addressable-defect repair sub-step (pack-16, D39.24): when enabled, a FINAL @@ -1046,6 +1069,25 @@ func LoadPipeline(path string, models *Models, pair string, labels []string) (*P bad("gates.terminology is enabled but its role prompt is missing — expected %s (authored in the pair's own language, like the other role prompts)", p.Gates.Terminology.PromptPath) } } + // The §2 classifier phase: same structural contract as the render phase — an enabled phase must be able + // to fire (a resolvable model, a non-additive provider, its own budget, its own prompt). + if tg.ClassifyTypes { + cm := tg.ClassifierModel() + if _, ok := models.Models[cm]; !ok { + bad("gates.terminology.classify_model %q is not defined in models.yaml", cm) + } else if models.providerReasoning(cm) == "additive" { + bad("gates.terminology.classify_model %q sits on an ADDITIVE-billing provider and this phase carries no reasoning_max_tokens to reserve it with — the spend ceiling would be blind (D6.2/D13.6); pick a subset-billing model", cm) + } + if tg.ClassifyBudgetUSD <= 0 { + bad("gates.terminology.classify_budget_usd must be > 0 when classify_types is on (a phase that can never spend is a silent no-op)") + } + if pair != "" { + p.Gates.Terminology.ClassifyPromptPath = promptConventionPath(promptsRoot, pair, classifierRoleName) + if _, err := os.Stat(p.Gates.Terminology.ClassifyPromptPath); err != nil { + bad("gates.terminology.classify_types is on but its prompt is missing — expected %s (authored in the pair's own language, like the other role prompts)", p.Gates.Terminology.ClassifyPromptPath) + } + } + } } if len(problems) > 0 { return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - ")) @@ -1062,3 +1104,7 @@ const repairPromptDirName = "repair" // `//.md` convention every stage role uses — it is a role of the pair's prompt // pack, not a second prompt namespace. const terminologyRoleName = "terminologist" + +// classifierRoleName is the ROLE the §2 type-classifier prompt is keyed by, resolved through the same +// //.md convention as every other role prompt. +const classifierRoleName = "classifier" diff --git a/backend/internal/lang/data/injection.txt b/backend/internal/lang/data/injection.txt index c30c02f7..1d2ef0eb 100644 --- a/backend/internal/lang/data/injection.txt +++ b/backend/internal/lang/data/injection.txt @@ -10,3 +10,4 @@ ru unverified_marker ⟨проверить⟩ ru gender_male (муж. — мужские родовые формы) ru gender_female (жен. — женские родовые формы) ru gender_hidden (пол СКРЫТ до раскрытия — избегай родовых форм; при неизбежности — мужские) +ru gender_neuter (ср. — средний род: используй родовые формы среднего рода) diff --git a/backend/internal/lang/data/target-ru.txt b/backend/internal/lang/data/target-ru.txt index b7838ac2..641e34df 100644 --- a/backend/internal/lang/data/target-ru.txt +++ b/backend/internal/lang/data/target-ru.txt @@ -184,3 +184,47 @@ translit_interjection ауч translit_interjection упс translit_interjection кья translit_interjection десу +# decl_suffix: the productive Russian inflectional endings the decl-aware post-check stems a word by +# (bank-quality §3, primitive C). It lets dstFormPresent accept a term rendered in an oblique case +# («Фан Юаню» for «Фан Юань») WITHOUT the seed listing every form, closing the measured decl-142/142-null +# false-miss noise. TARGET data — the suffix set is ru-specific; the stemmer ALGORITHM (strip the longest +# listed ending that leaves a stem ≥ min length) stays generic Go and a target with no decl_suffix rows +# gets no stemming (inert). It is a FLAGGER aid, biased to ACCEPT declensions; it never gates. Ordered +# longest-first in code, so listing order here is free. +decl_suffix ами +decl_suffix ями +decl_suffix ого +decl_suffix его +decl_suffix ому +decl_suffix ему +decl_suffix ыми +decl_suffix ими +decl_suffix ах +decl_suffix ях +decl_suffix ов +decl_suffix ев +decl_suffix ом +decl_suffix ём +decl_suffix ем +decl_suffix ой +decl_suffix ей +decl_suffix ый +decl_suffix ий +decl_suffix ая +decl_suffix яя +decl_suffix ое +decl_suffix ее +decl_suffix ым +decl_suffix им +decl_suffix ых +decl_suffix их +decl_suffix ую +decl_suffix юю +decl_suffix а +decl_suffix я +decl_suffix у +decl_suffix ю +decl_suffix е +decl_suffix ы +decl_suffix и +decl_suffix о diff --git a/backend/internal/lang/embedded.go b/backend/internal/lang/embedded.go index aa30488b..80524914 100644 --- a/backend/internal/lang/embedded.go +++ b/backend/internal/lang/embedded.go @@ -395,6 +395,7 @@ type InjectionTexts struct { GenderMale string // " (муж. — …)" DC3 gender directive (leading space significant) GenderFemale string // " (жен. — …)" GenderHidden string // " (пол СКРЫТ …)" + GenderNeuter string // " (ср. — …)" — a neuter entity/creature (row 84); "" for a target with no row } // HasData reports whether the target has injection texts (its header is present). The renderers use it to @@ -452,6 +453,8 @@ func parseInjection(b []byte) (map[string]*InjectionTexts, error) { t.GenderFemale = val case "gender_hidden": t.GenderHidden = val + case "gender_neuter": + t.GenderNeuter = val default: return nil, fmt.Errorf("line %d: unknown injection key %q", i+1, key) } diff --git a/backend/internal/lang/script.go b/backend/internal/lang/script.go index 78bc9531..53b928a5 100644 --- a/backend/internal/lang/script.go +++ b/backend/internal/lang/script.go @@ -82,3 +82,17 @@ func IsCJKScriptLang(lng string) bool { } return false } + +// SeriesMorphology reports whether a SOURCE language forms rune-morpheme SERIES the terminologist should +// co-batch (甲等/乙等/丙等 — grades sharing the head 等), and whether that shared head is final. Series apply +// to DENSE (CJK) scripts, where one rune ≈ one morpheme so a single-rune difference is a real minimal pair; +// in an alphabetic source care/core differ in one letter by coincidence, so the channel stays off. CJK noun +// compounds are modifier-HEAD, so the generic word is the trailing rune(s): head-final. A linguistic constant +// like cjkScriptNames, not a pair/book branch — the one place to revisit if a non-head-final dense script is +// ever added. +func SeriesMorphology(lng string) (enabled, headFinal bool) { + if IsCJKScriptLang(lng) { + return true, true + } + return false, false +} diff --git a/backend/internal/lang/stemmer.go b/backend/internal/lang/stemmer.go new file mode 100644 index 00000000..beec1f14 --- /dev/null +++ b/backend/internal/lang/stemmer.go @@ -0,0 +1,101 @@ +package lang + +import ( + "sort" + "strings" + "unicode" +) + +// stemmer.go: the conservative TARGET stemmer (bank-quality §3, primitive C). It strips one inflectional +// ending off a word to get its stem, so a decl-aware check accepts an oblique case («мечом») against its +// nominative («меч») WITHOUT the seed listing every form — the fix for the decl-142/142-null false-miss +// noise, and the backstop the checker omission-detector shares. The ALGORITHM is generic; the ending REGISTRY +// is target data (decl_suffix in data/target-.txt), so a target with none gets an inert stemmer (exact +// match only). A FLAGGER aid, biased to ACCEPT declensions; it never gates. + +// minStemRunes is the shortest stem a suffix strip may leave. Below it a "stem" is too short to distinguish +// two words (стripping «а» off «дома» is fine → «дом»; stripping it off «яма» to «ям» is not worth trusting), +// so the ending is not stripped and the word stands whole. Conservative on the side of NOT conflating words. +const minStemRunes = 3 + +// TargetStemmer strips a registry ending off a word. The zero value is a valid inert stemmer (no endings → +// Stem is the identity → SameStem is exact equality), which is what a target with no decl_suffix data gets. +type TargetStemmer struct { + suffixes []string // registry endings, longest-first +} + +// NewTargetStemmer builds the stemmer from a target's decl_suffix registry. No registry → an inert stemmer. +func NewTargetStemmer(tc TargetChecks) TargetStemmer { + sfx := append([]string(nil), tc.List("decl_suffix")...) + // Longest-first so Stem strips the most specific ending (е.g. «ому» before «у»); ties by bytes for a + // stable, deterministic order. + sort.SliceStable(sfx, func(i, j int) bool { + if a, b := len([]rune(sfx[i])), len([]rune(sfx[j])); a != b { + return a > b + } + return sfx[i] < sfx[j] + }) + return TargetStemmer{suffixes: sfx} +} + +// Enabled reports whether the stemmer carries any endings — false for a target with no decl_suffix data, so a +// consumer can skip the whole stem branch. +func (s TargetStemmer) Enabled() bool { return len(s.suffixes) > 0 } + +// Stem returns word with its longest registry ending removed, provided the remaining stem stays ≥ +// minStemRunes; otherwise the word is returned unchanged. Case-insensitive (folded to lower), so a match is +// case-blind. An empty or all-short word returns folded-as-is. +func (s TargetStemmer) Stem(word string) string { + w := strings.ToLower(word) + rs := []rune(w) + if len(rs) < minStemRunes { + return w + } + for _, suf := range s.suffixes { + sr := []rune(suf) + if len(rs)-len(sr) < minStemRunes { + continue // stripping this ending would leave too short a stem + } + if strings.HasSuffix(w, suf) { + return string(rs[:len(rs)-len(sr)]) + } + } + return w +} + +// SameStem reports whether two words share a stem under this stemmer — the decl-aware equality a consumer +// uses to accept one form of a word against another. Two identical words trivially match; the value the +// stemmer adds is matching «мечом»/«меча»/«меч». Never true for an inert stemmer beyond exact equality. +func (s TargetStemmer) SameStem(a, b string) bool { + la, lb := strings.ToLower(a), strings.ToLower(b) + if la == lb { + return true + } + if !s.Enabled() { + return false + } + return s.Stem(la) == s.Stem(lb) +} + +// TokenizeWords splits a target string into its whole words (maximal letter runs), lower-cased. Script- +// generic (any Unicode letter), so it needs no per-language branch. Used to match a declined multi-word term +// head against the output. +func TokenizeWords(s string) []string { + var out []string + var cur []rune + flush := func() { + if len(cur) > 0 { + out = append(out, string(cur)) + cur = cur[:0] + } + } + for _, r := range strings.ToLower(s) { + if unicode.IsLetter(r) { + cur = append(cur, r) + continue + } + flush() + } + flush() + return out +} diff --git a/backend/internal/lang/stemmer_test.go b/backend/internal/lang/stemmer_test.go new file mode 100644 index 00000000..24eb7848 --- /dev/null +++ b/backend/internal/lang/stemmer_test.go @@ -0,0 +1,57 @@ +package lang + +import "testing" + +func TestTargetStemmerRuDeclension(t *testing.T) { + s := NewTargetStemmer(TargetChecksFor("ru")) + if !s.Enabled() { + t.Fatal("ru ships a decl_suffix registry, so the stemmer must be enabled") + } + // Common-noun oblique cases collapse to one stem — the measured decl-142/142-null case. + groups := [][]string{ + {"меч", "меча", "мечом", "мечи", "мечу"}, + {"апертура", "апертуры", "апертуру", "апертурой", "апертуре"}, + {"старейшина", "старейшину", "старейшины", "старейшиной"}, + {"источник", "источника", "источнику", "источнике"}, + {"гора", "горы", "гору", "горе", "горой"}, + } + for _, g := range groups { + for _, w := range g { + if !s.SameStem(g[0], w) { + t.Errorf("%q and %q must share a stem (%q vs %q)", g[0], w, s.Stem(g[0]), s.Stem(w)) + } + } + } + // It must NOT conflate distinct words: a shared prefix that is not an inflection. + for _, pair := range [][2]string{{"меч", "мечта"}, {"гора", "город"}, {"дом", "домна"}} { + if s.SameStem(pair[0], pair[1]) { + t.Errorf("%q and %q are different words and must not share a stem (%q vs %q)", pair[0], pair[1], s.Stem(pair[0]), s.Stem(pair[1])) + } + } +} + +func TestTargetStemmerInertWithoutRegistry(t *testing.T) { + var s TargetStemmer // zero value = a target with no decl_suffix data + if s.Enabled() { + t.Fatal("a stemmer with no registry must be inert") + } + if s.SameStem("меча", "меч") { + t.Fatal("an inert stemmer must fall back to EXACT equality only") + } + if !s.SameStem("меч", "меч") { + t.Fatal("an inert stemmer must still match identical words") + } +} + +func TestTokenizeWords(t *testing.T) { + got := TokenizeWords("Фан Юаню, к Горе!") + want := []string{"фан", "юаню", "к", "горе"} + if len(got) != len(want) { + t.Fatalf("tokens = %v, want %v", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("token %d = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/backend/internal/membank/memdecl_test.go b/backend/internal/membank/memdecl_test.go new file mode 100644 index 00000000..d92700f1 --- /dev/null +++ b/backend/internal/membank/memdecl_test.go @@ -0,0 +1,75 @@ +package membank + +import ( + "testing" + + "textmachine/backend/internal/lang" + "textmachine/backend/internal/store" + "textmachine/backend/internal/text" +) + +func present(t *testing.T, e *entry, output string, s lang.TargetStemmer) bool { + t.Helper() + norm := text.NormalizeTargetForm(output) + return dstFormPresent(e, []rune(norm), lang.TokenizeWords(norm), s) +} + +// TestDstFormPresentAcceptsDeclension is the §3 pin: a term rendered in an oblique case is ACCEPTED against +// its nominative even when the seed listed no decl forms (the measured 142/142-null noise), while a term +// genuinely absent is still a miss. Multi-word terms match component-wise, whichever word inflected. +func TestDstFormPresentAcceptsDeclension(t *testing.T) { + s := lang.NewTargetStemmer(lang.TargetChecksFor("ru")) + single := &entry{dst: "меч"} + if !present(t, single, "он поднял свой мечом высоко", s) { + t.Fatal("an oblique case of a single-word term must be accepted (мечом ~ меч)") + } + if present(t, single, "он поднял свой клинок высоко", s) { + t.Fatal("a genuinely absent term must still be a miss") + } + multi := &entry{dst: "гора Цинмао"} + if !present(t, multi, "они стояли у горы Цинмао", s) { + t.Fatal("a multi-word term with the head inflected must be accepted (горы Цинмао ~ гора Цинмао)") + } +} + +// TestDstFormPresentInertWithoutStemmer guards the generality path: a target with no decl_suffix registry +// (an inert stemmer) keeps EXACT-match behaviour, so it never falsely accepts a declined form. +func TestDstFormPresentInertWithoutStemmer(t *testing.T) { + var inert lang.TargetStemmer + e := &entry{dst: "меч"} + if present(t, e, "он поднял мечом", inert) { + t.Fatal("without a stemmer the declined form must NOT match") + } + if !present(t, e, "он поднял меч", inert) { + t.Fatal("the exact nominative must still match with no stemmer") + } +} + +func TestGenderConstraintNoteNeuter(t *testing.T) { + tx := lang.InjectionTextsFor("ru") + if tx.GenderNeuter == "" { + t.Fatal("ru must ship a gender_neuter directive") + } + for _, g := range []string{"neuter", "n"} { + if got := genderConstraintNote(g, tx); got != tx.GenderNeuter { + t.Fatalf("gender %q must map to the neuter directive, got %q", g, got) + } + } + if genderConstraintNote("xyz", tx) != "" { + t.Fatal("an unknown gender must produce no directive (the row-84 silent no-op it once was)") + } +} + +func TestGenderVocabViolations(t *testing.T) { + entries := []store.GlossaryEntry{ + {Src: "甲", Dst: "А", Gender: "male"}, + {Src: "乙", Dst: "Б", Gender: "neuter"}, // now valid + {Src: "丙", Dst: "В", Gender: ""}, // absent is fine + {Src: "丁", Dst: "Г", Gender: "Male"}, // wrong case → a silent no-op today, must flag + {Src: "戊", Dst: "Д", Gender: "мужской"}, // a typo → must flag + } + bad := GenderVocabViolations(entries) + if len(bad) != 2 { + t.Fatalf("exactly the two off-vocabulary genders must flag, got %d: %v", len(bad), bad) + } +} diff --git a/backend/internal/membank/memory.go b/backend/internal/membank/memory.go index 05ab499b..c32a4d1d 100644 --- a/backend/internal/membank/memory.go +++ b/backend/internal/membank/memory.go @@ -174,6 +174,10 @@ type Bank struct { // they sit beside the automaton rather than inside it: nothing here can put them in keyOwners. voices []store.VoiceProfile pairs []store.AddressPair + // stemmer is the target-language decl stemmer (bank-quality §3): it lets the post-check accept an oblique + // case of a term against its nominative without the seed listing every form. The zero value is inert (a + // target with no decl_suffix registry), so a book that ships none keeps exact-match behaviour. + stemmer lang.TargetStemmer } // PickedEntry is one selected record for a chunk with its firing key and disposition. @@ -250,6 +254,9 @@ type BankInput struct { // hashing them would re-bill a book for authoring a profile that changed no byte the model sees. // The moment they are injected, they must fold — the D39.42 п.3 class, in the other direction. InjectVoice bool + // TargetStemmer is the decl-aware post-check stemmer (bank-quality §3). Zero value → inert (exact match + // only), so a caller that supplies none keeps the pre-§3 behaviour byte-for-byte. + TargetStemmer lang.TargetStemmer } // Materialize builds a Bank from the book's stored glossary rows alone — the pre-pack-19 form, kept for @@ -265,7 +272,7 @@ func Materialize(rows []store.GlossaryEntry, gateOn bool) *Bank { // the approved glossary OR to the deterministic machinery is a loud --resnapshot (F1). func MaterializeBank(in BankInput, gateOn bool) *Bank { rows := in.Rows - b := &Bank{keyOwners: map[string][]int{}, voices: in.Voices, pairs: in.Pairs} + b := &Bank{keyOwners: map[string][]int{}, voices: in.Voices, pairs: in.Pairs, stemmer: in.TargetStemmer} var allKeys []string seenKey := map[string]bool{} @@ -383,6 +390,7 @@ func ComputeVersionScopedIn(in BankInput, gateOn, excludeMined bool) string { h.Write([]byte(text.NormVersion() + "\x00" + matchVersion + "\x00")) h.Write([]byte("gate:" + strconv.FormatBool(gateOn) + "\x00")) hasUnverified := false + hasNeuter := false // a neuter row now RENDERS a directive (bank-quality §3) where it rendered nothing before // inScope collects the characters this fold's ROW scope actually contains, so the voice/address fold // below can apply the SAME scope before deciding anything (see the loop at the end). inScope := map[[2]string]bool{} @@ -406,6 +414,9 @@ func ComputeVersionScopedIn(in BankInput, gateOn, excludeMined bool) string { if r.Status != "approved" { hasUnverified = true } + if r.Gender == "neuter" || r.Gender == "n" { + hasNeuter = true + } inScope[[2]string{r.Src, r.Sense}] = true // A fixed, length-prefixed field layout so no content can forge a boundary. writeField(h, r.Status) @@ -437,6 +448,13 @@ func ComputeVersionScopedIn(in BankInput, gateOn, excludeMined bool) string { if hasUnverified { h.Write([]byte("editor-unverified-section-v1\x00")) } + // The same scoped-render pattern for the §3 neuter directive: gender:neuter was a SILENT no-op before, + // so a book carrying one renders new wire bytes on the same row set — the editor-unverified hole again. + // Folded ONLY for a scope that HAS a neuter row, so a bank without one is byte-identical to before and + // re-bills nobody; a neuter-bearing book takes a loud, RE-PINNABLE --resnapshot (bank-only move). + if hasNeuter { + h.Write([]byte("neuter-directive-v1\x00")) + } // The pack-19 record types, appended AFTER everything above so a book without them is byte-identical // to the pre-pack-19 hash by construction, not by argument. // @@ -708,6 +726,9 @@ func RenderGlossaryBlock(injected []PickedEntry, tx lang.InjectionTexts) string // ALSO appends the confirmed-gender annotation, so a named term's gender reaches the TRANSLATOR wire, not // only the editor (the owner's «род должен доезжать» directive). A gendered confirmed term shifts the // draft injected bytes → the wire, so a loud --resnapshot; a genderless bank is byte-identical to v2. +// (The bank-quality §3 neuter directive is NOT versioned here: a blanket bump is un-re-pinnable and would +// re-snapshot every book on earth. It is folded SCOPED — like the editor-unverified section — so only a +// book that actually carries a neuter row moves; see the neuter-directive tag in ComputeVersionScopedIn.) const RenderFormatVersion = "renderfmt-v3-draft-gender+editor-src2dst+dc3-gender" // The editor's canonical-constraint block header and the glossary header are TARGET-language wire-text @@ -818,6 +839,8 @@ func genderConstraintNote(gender string, tx lang.InjectionTexts) string { return tx.GenderMale case "female", "f": return tx.GenderFemale + case "neuter", "n": + return tx.GenderNeuter case "hidden": return tx.GenderHidden } diff --git a/backend/internal/membank/mempostcheck.go b/backend/internal/membank/mempostcheck.go index 1b26e9ae..a80622d4 100644 --- a/backend/internal/membank/mempostcheck.go +++ b/backend/internal/membank/mempostcheck.go @@ -4,6 +4,7 @@ import ( "strings" "unicode" + "textmachine/backend/internal/lang" "textmachine/backend/internal/text" ) @@ -81,6 +82,7 @@ func (b *Bank) Postcheck(injected []PickedEntry, output string) PostcheckResult nout := text.NormalizeTargetForm(output) var res PostcheckResult noutRunes := []rune(nout) + outWords := lang.TokenizeWords(nout) // once for the whole chunk; the §3 stem branch reuses it per term for _, p := range injected { if !p.valid() { // a caller-built zero record carries no row — nothing to check (pack-16 tail) continue @@ -91,7 +93,7 @@ func (b *Bank) Postcheck(injected []PickedEntry, output string) PostcheckResult if strings.TrimSpace(p.entry.dst) == "" { // a ruby candidate with no dst yet — nothing to check continue } - present := dstFormPresent(p.entry, noutRunes) + present := dstFormPresent(p.entry, noutRunes, outWords, b.stemmer) if p.Disp != Confirmed { res.Shown++ if present { @@ -141,9 +143,17 @@ func (b *Bank) Postcheck(injected []PickedEntry, output string) PostcheckResult // The remaining inflection_gap (18/55: a plural rendered but only the singular seeded) is a // SEED completeness fix (Polygon), not a code one. noutRunes is the normalized output // pre-decomposed. -func dstFormPresent(e *entry, noutRunes []rune) bool { - if base := text.NormalizeTargetForm(e.dst); base != "" && containsWholeWord(noutRunes, []rune(base)) { - return true +func dstFormPresent(e *entry, noutRunes []rune, outWords []string, stemmer lang.TargetStemmer) bool { + if base := text.NormalizeTargetForm(e.dst); base != "" { + if containsWholeWord(noutRunes, []rune(base)) { + return true + } + // §3: accept an OBLIQUE case of the base when the seed listed no decl forms (the measured 142/142-null + // noise). A component-wise stem match — «горы Цинмао» for «гора Цинмао», «мечом» for «меч» — with no + // assumption about which word inflects. Inert for a target with no decl_suffix registry. + if declinedFormPresent(outWords, base, stemmer) { + return true + } } for _, f := range e.declForms { if f != "" && containsWholeWord(noutRunes, []rune(f)) { @@ -153,6 +163,33 @@ func dstFormPresent(e *entry, noutRunes []rune) bool { return false } +// declinedFormPresent reports whether the base rendering appears in the tokenized output with each of its +// words possibly inflected — a window of outWords the same length as base's words where every component +// shares its stem. Conservative (needs the whole phrase present, in order) and inert without a stemmer. +func declinedFormPresent(outWords []string, base string, stemmer lang.TargetStemmer) bool { + if !stemmer.Enabled() { + return false + } + bw := lang.TokenizeWords(base) + n := len(bw) + if n == 0 || n > len(outWords) { + return false + } + for i := 0; i+n <= len(outWords); i++ { + all := true + for k := 0; k < n; k++ { + if !stemmer.SameStem(outWords[i+k], bw[k]) { + all = false + break + } + } + if all { + return true + } + } + return false +} + // containsWholeWord reports whether form occurs in hay with a letter-boundary on both // ends (the char before the match is not a letter or is the start; likewise after). Runs // on runes so Cyrillic boundaries are correct. O(len(hay)·len(form)) — fine, the @@ -203,7 +240,9 @@ type SpoilerLeak struct { // // Pure and deterministic (rejected order, which Select fixes). Observability only — never a disposition. func (b *Bank) SpoilerLeaks(rejected []PickedEntry, output string) []SpoilerLeak { - nout := []rune(text.NormalizeTargetForm(output)) + norm := text.NormalizeTargetForm(output) + nout := []rune(norm) + outWords := lang.TokenizeWords(norm) var out []SpoilerLeak for _, p := range rejected { if !p.valid() || p.Sticky { @@ -212,7 +251,7 @@ func (b *Bank) SpoilerLeaks(rejected []PickedEntry, output string) []SpoilerLeak if strings.TrimSpace(p.entry.dst) == "" { continue // nothing to leak } - if !dstFormPresent(p.entry, nout) { + if !dstFormPresent(p.entry, nout, outWords, b.stemmer) { continue } out = append(out, SpoilerLeak{ diff --git a/backend/internal/membank/memseed.go b/backend/internal/membank/memseed.go index 52cfcb15..9275d936 100644 --- a/backend/internal/membank/memseed.go +++ b/backend/internal/membank/memseed.go @@ -491,9 +491,31 @@ func SeedLint(path string) error { if problems := ApprovedSharedKeyCollisions(entries); len(problems) > 0 { return fmt.Errorf("glossary seed %s shared-key collisions:\n - %s", path, strings.Join(problems, "\n - ")) } + if bad := GenderVocabViolations(entries); len(bad) > 0 { + return fmt.Errorf("glossary seed %s unknown gender values (each would SILENTLY inject no gender directive — row 84):\n - %s", path, strings.Join(bad, "\n - ")) + } return nil } +// knownGenders is the gender vocabulary genderConstraintNote understands. Matched EXACTLY (no trim/case +// fold), mirroring that switch: a value it does not recognise injects no directive at all, so " male" or +// "Male" is as silent a no-op as a typo and is flagged the same way. +var knownGenders = map[string]bool{ + "": true, "male": true, "m": true, "female": true, "f": true, "neuter": true, "n": true, "hidden": true, +} + +// GenderVocabViolations lists seed rows whose gender is outside the known vocabulary — the row-84 class where +// gender:neuter (now valid) was an unnoticed no-op. Deterministic, seed order preserved. +func GenderVocabViolations(entries []store.GlossaryEntry) []string { + var out []string + for _, e := range entries { + if !knownGenders[e.Gender] { + out = append(out, fmt.Sprintf("%s→%s: gender %q is not one of male|female|neuter|hidden", e.Src, e.Dst, e.Gender)) + } + } + return out +} + // ruby classifier classes (deterministic HINTS for human promotion). const ( rubyClassName = "name_candidate" // all-Han base + all-kana reading: the furigana-name SHAPE diff --git a/backend/internal/pipeline/mining.go b/backend/internal/pipeline/mining.go index 9dd9c201..60e0bf90 100644 --- a/backend/internal/pipeline/mining.go +++ b/backend/internal/pipeline/mining.go @@ -135,11 +135,12 @@ func (r *Runner) runBankMiningStop(ctx context.Context, chunks []chunk.Chunk, dr } return false, nil } - consolidated, tres, err := r.runTerminologist(ctx, draftSnapshot, cands) + consolidated, classified, tres, err := r.runTerminologist(ctx, draftSnapshot, cands) if err != nil { return false, err } r.lastTerminology = tres + mined = attachClassifiedType(mined, classified) mined = attachConsolidatedDst(mined, consolidated) // Non-empty delta → write the owner signature map (the mined seed-delta YAML) and STOP before the edit wave. diff --git a/backend/internal/pipeline/miningstop_join_test.go b/backend/internal/pipeline/miningstop_join_test.go index 43177551..16c09f3e 100644 --- a/backend/internal/pipeline/miningstop_join_test.go +++ b/backend/internal/pipeline/miningstop_join_test.go @@ -59,6 +59,7 @@ type miningStopOpts struct { glossarySeed string // glossary seed YAML body; "" = no seed source string // book source (defaults to miningStopSource) terminology bool // enable the terminologist role (pack-20) with a fixture prompt + classify bool // enable the §2 classifier phase (needs terminology) with a fixture prompt batchRunes int // gates.terminology.batch_runes; 0 = engine default (one batch for this corpus) budgetUSD float64 // gates.terminology.budget_usd; 0 = 1.0 (effectively unbounded here) targetScript string // gates.terminology.target_script; "" = Cyrillic (the fixtures' target) @@ -92,6 +93,9 @@ func setupMiningStopProject(t *testing.T, providerURL string, o miningStopOpts) if o.batchRunes > 0 { gates += fmt.Sprintf(" batch_runes: %d\n", o.batchRunes) } + if o.classify { + gates += " classify_types: true\n classify_budget_usd: 1.0\n" + } } bookPath := setupProjectOpts(t, providerURL, projectOpts{ source: o.source, @@ -106,6 +110,11 @@ func setupMiningStopProject(t *testing.T, providerURL string, o miningStopOpts) writeFile(t, filepath.Join(dir, "pairs", "zh-ru.yaml"), "pair: zh-ru\nprompts_root: ../prompts\n") writeFile(t, filepath.Join(dir, "prompts", "zh-ru", "terminologist.md"), "Ты — терминолог {{source_lang}}→{{target_lang}}.\n---USER---\nТермины книги:\n\n{{text}}") + if o.classify { + // A DISTINCT user header so isClassifierBody can tell a classify call from a render call on the wire. + writeFile(t, filepath.Join(dir, "prompts", "zh-ru", "classifier.md"), + "Ты — классификатор {{source_lang}}→{{target_lang}}.\n---USER---\nКлассы терминов:\n\n{{text}}") + } } packRoot, err := filepath.Abs("../../configs/langpacks") @@ -300,6 +309,59 @@ func TestMiningStopWHATSurvivesResume(t *testing.T) { // candidate-block label the role prompt writes, which no other prompt does). func isTerminologyBody(body string) bool { return strings.Contains(body, "Термины книги") } +// isClassifierBody detects the §2 classifier call by its distinct fixture user header. +func isClassifierBody(body string) bool { return strings.Contains(body, "Классы терминов") } + +// TestClassifierCorrectsTypeBeforeRender is the §2 pin: a focused pass re-types a candidate BEFORE the +// render, and the corrected type reaches the banked row (attachClassifiedType) and the run's counters and +// ledger — on its OWN cost axis, separate from the render phase. Here the classifier re-types 青茅山 from the +// draft `place` to `term`; the signature map must record the corrected type. +func TestClassifierCorrectsTypeBeforeRender(t *testing.T) { + rec := &reqRec{} + var sawClassify, sawRenderAfterClassify bool + srv := newJSONProvider(rec, func(body string) (string, string) { + if isClassifierBody(body) { + sawClassify = true + return "方源\tname\n青茅山\tterm", "stop" // re-type the place to a realia term + } + if isTerminologyBody(body) { + sawRenderAfterClassify = sawClassify // the render must run AFTER the classify phase + return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop" + } + return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop" + }) + defer srv.Close() + bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{terminology: true, classify: true}) + + r := newVerifyRunner(t, bookPath) + defer r.Close() + runToSignatureStop(t, r) + yamlMap := readSignatureMap(t, r) + + if !sawClassify { + t.Fatal("the classifier phase must have made a call") + } + if !sawRenderAfterClassify { + t.Fatal("the render phase must run AFTER the classifier phase (the corrected type primes the render)") + } + if r.lastTerminology.Reclassified != 1 { + t.Fatalf("exactly one type was re-derived, got Reclassified=%d", r.lastTerminology.Reclassified) + } + // The corrected type reaches the banked row, not the draft `place`. + place := termBlock(t, yamlMap, "青茅山") + if !strings.Contains(place, "type: term") { + t.Fatalf("the classifier's corrected type must be the banked type:\n%s", place) + } + // The classify spend is attributable on its OWN cost axis, apart from the render. + cspent, err := r.Store.RoleSpentUSD("test-book", roleClassifier) + if err != nil { + t.Fatal(err) + } + if cspent <= 0 || r.lastTerminology.ClassifyCostUSD <= 0 { + t.Fatalf("classify spend must be on its own ledger axis: ledger=%v result=%v", cspent, r.lastTerminology.ClassifyCostUSD) + } +} + // TestTerminologistConsolidatesBankIntoDraftRows is the core pack-20 pin: with the role on, the bank the // owner meets at the stop carries a CONSOLIDATED rendering per term, emitted in the `draft` mode of // §C2-7 — the mode that reaches the wire with ⟨проверить⟩ — while a term the role declines stays `auto`, diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go index 5fd065fd..006065fc 100644 --- a/backend/internal/pipeline/runner.go +++ b/backend/internal/pipeline/runner.go @@ -70,6 +70,9 @@ 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 + // classifierTemplate is the pair's §2 type-classifier prompt, loaded only when classify_types is on. nil + // otherwise → the classifier phase is a no-op and the draft heuristic type stands. + classifierTemplate *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. diff --git a/backend/internal/pipeline/seeding.go b/backend/internal/pipeline/seeding.go index bba65610..76e75b76 100644 --- a/backend/internal/pipeline/seeding.go +++ b/backend/internal/pipeline/seeding.go @@ -6,6 +6,7 @@ import ( "sort" "strings" "textmachine/backend/internal/chunk" + "textmachine/backend/internal/lang" "textmachine/backend/internal/membank" "textmachine/backend/internal/store" ) @@ -131,7 +132,10 @@ func (r *Runner) seedGlossary(ctx context.Context) error { // holds the injection conditional until the polygon experiment. It is the fold's condition, so while // it is false a book with voice rows hashes exactly as it did without them and nobody re-pays for // authoring a profile. Wiring the injection means setting it and accepting a full --resnapshot. - bankIn := membank.BankInput{Rows: rows, Voices: storedVoices, Pairs: storedPairs} + // The §3 decl stemmer is target data, constant per book; baseIn copies bankIn below, so both banks share + // it. A target with no decl_suffix registry yields an inert stemmer → exact-match post-check as before. + bankIn := membank.BankInput{Rows: rows, Voices: storedVoices, Pairs: storedPairs, + TargetStemmer: lang.NewTargetStemmer(lang.TargetChecksFor(r.Book.TargetLang))} r.memory = membank.MaterializeBank(bankIn, r.Pipeline.Gates.Glossary.PostcheckGate) // The DRAFT wave selects over a BASE-scoped bank (Source:mined excluded) so its injection is // byte-identical across a bank-mining enrichment — matching the draft-wave snapshot (baseMemoryVersion), diff --git a/backend/internal/pipeline/terminologist.go b/backend/internal/pipeline/terminologist.go index 66a0effa..a88ac068 100644 --- a/backend/internal/pipeline/terminologist.go +++ b/backend/internal/pipeline/terminologist.go @@ -45,6 +45,11 @@ import ( // answerable with no migration. const roleTerminologist = "terminologist" +// roleClassifier is the §2 type-classifier phase's cost axis. Its own role marker keeps its checkpoints, +// request_log rows and ledger entries on a queryable class of their own and on their own request-hash axis, +// separate from the render phase — "what did classification spend" is answerable with no migration. +const roleClassifier = "classifier" + // terminologyStageName is the synthetic stage name the calls carry. It is NOT a pipeline stage (a stage // would join a wave, take a snapshot axis and a chunk_status row per unit); it is an addressing label. const terminologyStageName = "terminology" @@ -52,7 +57,7 @@ const terminologyStageName = "terminology" // terminologyVersion versions the ASSEMBLY algorithm (merge → KWIC → §C2-3 ranking → batching → parse). It // is deliberately NOT snapshot-folded — see config.TerminologyGate — but it is logged with the run so a // signature map can be attributed to the algorithm that produced it. -const terminologyVersion = "terminology-v1-merge+kwic+c2-3" +const terminologyVersion = "terminology-v2-merge+kwic+c2-3+series" // Engine defaults for the block sizing. They bound ONE call's input; the whole book is covered by batching. const ( @@ -78,10 +83,14 @@ type terminologyResult struct { // OBSERVABILITY, never a gate: the rows stay unverified either way, and this is what tells the owner // which of them to look at first. CanonConflicts int - CostUSD float64 // what THIS run paid - CumUSD float64 // what the calls cost in total (a replayed checkpoint is $0 now, not then) + CostUSD float64 // what THIS run's RENDER phase paid + CumUSD float64 // what the render calls cost in total (a replayed checkpoint is $0 now, not then) Fresh bool // at least one call actually reached the provider this run EstimateUSD float64 // the pre-call projection, logged before any money moves + // Reclassified is how many candidate types the §2 classifier phase actually changed; ClassifyCostUSD is + // what that phase paid this run. Both zero when classify_types is off. + Reclassified int + ClassifyCostUSD float64 } // loadTerminologyTemplate loads the pair's terminologist prompt when the gate is on. Mirrors @@ -95,6 +104,21 @@ func (r *Runner) loadTerminologyTemplate() error { return err } r.terminologyTemplate = tpl + return r.loadClassifierTemplate() +} + +// loadClassifierTemplate loads the pair's §2 classifier prompt when classify_types is on. Off → nil, and the +// classifier phase is inert. Called from loadTerminologyTemplate: the classifier only exists as a phase of +// the terminology gate. +func (r *Runner) loadClassifierTemplate() error { + if !r.Pipeline.Gates.Terminology.ClassifyTypes { + return nil + } + tpl, err := LoadPromptTemplate(r.Pipeline.Gates.Terminology.ClassifyPromptPath) + if err != nil { + return err + } + r.classifierTemplate = tpl return nil } @@ -159,6 +183,18 @@ func (r *Runner) buildBankCandidates(mined []miner.Term, observed []terminology. _, kwicPer, kwicWidth := r.terminologyOpts() cands = terminology.AttachKWIC(cands, chunks, kwicPer, kwicWidth) + opts := r.scoreOpts() + for i := range cands { + terminology.ScoreVariants(&cands[i], opts) + } + return cands +} + +// scoreOpts builds the §C2-3 scoring options — the approved-neighbour anchor and the pair's transliteration +// conformance, which routes on TYPE (only name/place answer to the transliteration convention). Extracted so +// the initial candidate build and the §2 post-classify re-score share ONE definition and can never disagree +// about how a variant is scored. +func (r *Runner) scoreOpts() terminology.ScoreOpts { opts := terminology.ScoreOpts{Neighbours: r.approvedNeighbours()} if r.pack != nil { pack := r.pack @@ -169,10 +205,7 @@ func (r *Runner) buildBankCandidates(mined []miner.Term, observed []terminology. return miner.PalladiusConformance(dst, pack) } } - for i := range cands { - terminology.ScoreVariants(&cands[i], opts) - } - return cands + return opts } // approvedNeighbours is the already-signed bank, as the §C2-3 "agreement with approved siblings" anchor. @@ -199,10 +232,10 @@ func (r *Runner) approvedNeighbours() []terminology.Neighbour { // A term absent from the returned map keeps NO dst — the auto branch of §C2-7. That is the deliberate // reading of silence: a role that did not answer has not decided, and an undecided term must not enter the // wire carrying a guess. -func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []terminology.Candidate) (map[string]string, terminologyResult, error) { +func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []terminology.Candidate) (map[string]string, map[string]string, terminologyResult, error) { var res terminologyResult if !r.Pipeline.Gates.Terminology.Enabled || r.terminologyTemplate == nil || len(cands) == 0 { - return nil, res, nil + return nil, nil, res, nil } res.Candidates = len(cands) for _, c := range cands { @@ -211,96 +244,65 @@ func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []te } } batchRunes, _, _ := r.terminologyOpts() - batches := terminology.Batch(cands, batchRunes) + + // §2 type re-derivation, BEFORE the render: a focused pass re-classifies every candidate, so a realia + // surface mistyped as a name (元石) is no longer FORCED to a transliteration. The corrected type routes + // conformance (re-scored here), primes the wire block, and is returned so the caller stamps it as the + // banked type. Off (or unanswered) → the heuristic draft type stands. + classified, crun, cerr := r.runClassifier(ctx, snapID, cands) + if cerr != nil { + return nil, nil, res, cerr + } + res.ClassifyCostUSD = crun.costUSD + if len(classified) > 0 { + res.Reclassified = applyTypes(cands, classified) + opts := r.scoreOpts() + for i := range cands { + terminology.ScoreVariants(&cands[i], opts) + } + } + + // §1: co-batch grade/rank series so the role picks ONE generic head for the whole set. The pair-data — + // whether the source forms rune-morpheme series and where the head sits — comes from the language layer, + // so the batcher stays pair-agnostic and an alphabetic source takes a byte-identical, series-free path. + sEnabled, headFinal := lang.SeriesMorphology(r.Book.SourceLang) + seriesID := terminology.DetectSeries(cands, terminology.SeriesParams{Enabled: sEnabled, HeadFinal: headFinal}) + batches := terminology.Batch(cands, batchRunes, seriesID) res.Batches = len(batches) - // The ESTIMATE comes before any money moves — the directive's «смета ДО живого вызова». It is the same - // arithmetic the reservation uses, summed over the batches, so the number the operator reads and the - // number the ledger reserves cannot drift apart. - // The signed bank is read ONCE for the whole role, not per batch: it is the same law for every batch and - // a store read per batch would be a query multiplied by a number the operator does not control. + // The signed bank is read ONCE for the whole role, not per batch: it is the same law for every batch. canon := r.approvedNeighbours() - msgsPer := make([][]llm.Message, len(batches)) - for i, b := range batches { - m, err := r.terminologyMessages(b, canon) - if err != nil { - return nil, res, err - } - msgsPer[i] = m - res.EstimateUSD += r.terminologyEstimateUSD(m) + plan := bankRolePlan{ + role: roleTerminologist, model: r.Pipeline.Gates.Terminology.Model, budgetUSD: r.Pipeline.Gates.Terminology.BudgetUSD, + messages: func(b []terminology.Candidate) ([]llm.Message, error) { return r.terminologyMessages(b, canon) }, } - r.Log.InfoContext(ctx, "terminology: consolidating the bank over the whole book", - "book", r.Book.BookID, "candidates", res.Candidates, "reverse_section", res.Reverse, - "batches", res.Batches, "model", r.Pipeline.Gates.Terminology.Model, - "estimate_usd", fmt.Sprintf("%.6f", res.EstimateUSD), "budget_usd", r.Pipeline.Gates.Terminology.BudgetUSD, - "version", terminologyVersion) + run, err := r.runBankRoleBatches(ctx, snapID, plan, batches, "render") + if err != nil { + return nil, nil, res, err + } + res.EstimateUSD, res.CostUSD, res.CumUSD, res.Fresh = run.estimateUSD, run.costUSD, run.cumUSD, run.fresh - spent, err := r.Store.RoleSpentUSD(r.Book.BookID, roleTerminologist) - if err != nil { - return nil, res, fmt.Errorf("pipeline: terminology budget read: %w", err) - } out := map[string]string{} - st := config.Stage{Name: terminologyStageName, Role: roleTerminologist, Model: r.Pipeline.Gates.Terminology.Model} - job, err := r.Store.EnsureJob(r.Book.BookID, 0, terminologyStageName, snapID) - if err != nil { - return nil, res, fmt.Errorf("pipeline: terminology job: %w", err) - } for i, b := range batches { - // The synthetic chunk addresses the batch: chapter 0 is the BOOK level (no real chapter is 0), and - // the batch ordinal is the chunk index, so two batches can never collide on one checkpoint. - ch := chunk.Chunk{Chapter: 0, ChunkIdx: i} - paid, perr := r.terminologyCheckpointExists(st, snapID, ch, msgsPer[i]) - if perr != nil { - return nil, res, perr + if run.texts[i] == "" { + continue } - // The budget is checked BEFORE the call and against what the call would COST, not after the fact - // against what has been spent: a check on spend alone lets the last permitted call push the total - // past the ceiling by its own size, so the number in the config is not the bound it looks like. The - // projection is the reservation's own upper bound (it sizes by max_tokens), so this refuses on the - // conservative side — it can decline a call that would have fitted, never permit one that does not. - if want := r.terminologyEstimateUSD(msgsPer[i]); !paid && spent+want > r.Pipeline.Gates.Terminology.BudgetUSD { - r.Log.WarnContext(ctx, "terminology budget would be exceeded by the next batch; the remaining terms stay unconsolidated (status:auto)", - "book", r.Book.BookID, "spent_usd", fmt.Sprintf("%.6f", spent), "next_batch_usd", fmt.Sprintf("%.6f", want), - "budget_usd", r.Pipeline.Gates.Terminology.BudgetUSD, "batches_left", len(batches)-i) - break - } - att, aerr := r.runTerminologyAttempt(ctx, st, snapID, ch, job, msgsPer[i]) - if aerr != nil { - // A ceiling denial must not abort the book: this step is optional and the draft wave is already - // paid for. Degrade to "no consolidation" exactly as the repair sub-step degrades. - if errors.Is(aerr, errReserveCeiling) { - r.Log.WarnContext(ctx, "terminology call denied by a USD ceiling; the bank stays unconsolidated", "book", r.Book.BookID) - break - } - return nil, res, aerr - } - res.CostUSD += att.runCost - res.CumUSD += att.cumCost - res.Fresh = res.Fresh || att.freshCall - spent += att.runCost - keys := make([]string, 0, len(b)) - for _, c := range b { - keys = append(keys, c.Key) - } - got, st := terminology.ParseReply(att.text, keys, text.NormalizeSourceKey, r.targetScript) + got, st := terminology.ParseReply(run.texts[i], candKeys(b), 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. + // 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. - // Say it out loud instead — the run still continues, because an unconsolidated bank is the state - // this step started from, not a broken one. + // A batch that came back with NOTHING usable is a paid call that bought no terminology. Silence here + // would spend the money, mark every term "the role declined" and exit 0. Say it out loud; the run still + // continues, because an unconsolidated bank is the state 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", st.Bad, - "reply_chars", len(att.text), "cost_usd", fmt.Sprintf("%.6f", att.runCost)) + "book", r.Book.BookID, "batch", i, "terms", len(b), "bad_lines", st.Bad, "reply_chars", len(run.texts[i])) } for k, v := range got { out[k] = v @@ -329,11 +331,24 @@ func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []te r.Log.WarnContext(ctx, "terminology: consolidated renderings contradict the SIGNED bank; they stay unverified (⟨проверить⟩) and are the first rows to review at the stop", "book", r.Book.BookID, "conflicts", len(conflicts), "terms", strings.Join(named, "; ")) } + // The $0 label screen (§2 warm-run hygiene): a name/place row whose rendering was clearly TRANSLATED is a + // label/rendering disagreement worth a human's eye. It is a review FLAG, never a gate, and explicitly not + // a safety net for the transliteration harm — the classifier phase is what prevents that (see + // terminology.TypeLabelMismatches). Named, not a bare count, so the operator knows which rows to open. + if flags := terminology.TypeLabelMismatches(labelRows(cands, out)); len(flags) > 0 { + named := make([]string, 0, len(flags)) + for _, f := range flags { + named = append(named, fmt.Sprintf("%s [%s]→%q", f.Src, f.Type, f.Dst)) + } + r.Log.WarnContext(ctx, "terminology: name/place rows carry a translated rendering — a label/rendering mismatch to review (hygiene flag, not a gate)", + "book", r.Book.BookID, "rows", len(flags), "terms", strings.Join(named, "; ")) + } r.Log.InfoContext(ctx, "terminology finished", "book", r.Book.BookID, "consolidated", res.Consolidated, "declined", res.Declined, "unanswered", res.Unanswered, - "bad_lines", res.BadLines, "off_language", res.OffLanguage, - "canon_conflicts", res.CanonConflicts, "cost_usd", fmt.Sprintf("%.6f", res.CostUSD)) - return out, res, nil + "reclassified", res.Reclassified, "bad_lines", res.BadLines, "off_language", res.OffLanguage, + "canon_conflicts", res.CanonConflicts, "cost_usd", fmt.Sprintf("%.6f", res.CostUSD), + "classify_cost_usd", fmt.Sprintf("%.6f", res.ClassifyCostUSD)) + return out, classified, res, nil } // terminologyMessages renders ONE batch into the wire messages: the pair's authored role prompt (system), @@ -356,10 +371,87 @@ func (r *Runner) terminologyMessages(batch []terminology.Candidate, canon []term // cost more than the candidates it accompanies. const terminologyCanonCap = 40 -// terminologyCallBudget resolves the model and max_tokens of a terminology call — ONE definition, so the -// checkpoint probe and the call itself can never address different request hashes (the repair precedent). -func (r *Runner) terminologyCallBudget(msgs []llm.Message) (string, int) { - model := r.Pipeline.Gates.Terminology.Model +// runClassifier is the §2 type-classifier phase: a focused pass on the same candidate batches that returns +// key → corrected type. Off (or no template) → a nil map and a zero run, so the terminologist keeps the +// draft heuristic type and pays nothing. The classifier needs no series co-batching (it decides a class per +// term) and no canon anchor (a class is a property of the source, not of the signed bank). +func (r *Runner) runClassifier(ctx context.Context, snapID string, cands []terminology.Candidate) (map[string]string, bankRoleRun, error) { + g := r.Pipeline.Gates.Terminology + if !g.ClassifyTypes || r.classifierTemplate == nil || len(cands) == 0 { + return nil, bankRoleRun{}, nil + } + batchRunes, _, _ := r.terminologyOpts() + batches := terminology.Batch(cands, batchRunes, nil) + plan := bankRolePlan{role: roleClassifier, model: g.ClassifierModel(), budgetUSD: g.ClassifyBudgetUSD, messages: r.classifierMessages} + run, err := r.runBankRoleBatches(ctx, snapID, plan, batches, "classify") + if err != nil { + return nil, run, err + } + out := map[string]string{} + bad := 0 + for i, b := range batches { + if run.texts[i] == "" { + continue + } + got, st := terminology.ParseTypes(run.texts[i], candKeys(b), text.NormalizeSourceKey) + bad += st.Bad + for k, v := range got { + out[k] = v + } + } + if bad > 0 { + r.Log.WarnContext(ctx, "terminology classify: some reply lines were off-vocabulary or malformed; those terms keep their draft type", + "book", r.Book.BookID, "bad_lines", bad) + } + return out, run, nil +} + +// classifierMessages renders ONE batch into the classifier's wire messages: the pair's authored classifier +// prompt (system) and the candidate block as the user turn, with no canon anchor. Engine-neutral, like the +// terminologist block — the class DEFINITIONS live in the pair's prompt, so a new pair needs no Go edit. +func (r *Runner) classifierMessages(batch []terminology.Candidate) ([]llm.Message, error) { + return MessagesWithInjection(r.classifierTemplate, + RenderVars{Book: r.Book, Text: terminology.RenderBatch(batch)}, "") +} + +// applyTypes stamps the classifier's corrected types onto the candidates in place and returns how many it +// actually changed. A term the classifier did not answer keeps its draft type. +func applyTypes(cands []terminology.Candidate, classified map[string]string) int { + n := 0 + for i := range cands { + if t, ok := classified[cands[i].Key]; ok && t != "" && t != cands[i].Type { + cands[i].Type = t + n++ + } + } + return n +} + +// candKeys is the batch's candidate keys, in order — what the reply parsers screen a reply against. +func candKeys(cands []terminology.Candidate) []string { + keys := make([]string, len(cands)) + for i, c := range cands { + keys[i] = c.Key + } + return keys +} + +// labelRows pairs each candidate that received a consolidated rendering with its (corrected) type, for the +// $0 label screen. +func labelRows(cands []terminology.Candidate, consolidated map[string]string) []terminology.LabelRow { + var rows []terminology.LabelRow + for _, c := range cands { + if dst := consolidated[c.Key]; dst != "" { + rows = append(rows, terminology.LabelRow{Src: c.Src, Type: c.Type, Dst: dst}) + } + } + return rows +} + +// bankCallBudget resolves the model and max_tokens of a bank-role call (terminologist OR classifier) — ONE +// definition, so the checkpoint probe and the call itself can never address different request hashes (the +// repair precedent). +func (r *Runner) bankCallBudget(model string, msgs []llm.Message) (string, int) { est := 0 for _, m := range msgs { est += EstimateTokens(m.Content) @@ -377,10 +469,10 @@ func (r *Runner) terminologyCallBudget(msgs []llm.Message) (string, int) { // terminologyReplyFloor is the headroom one batch's reply needs beyond the proportional estimate. const terminologyReplyFloor = 256 -// terminologyEstimateUSD projects ONE call's cost with the same price/estimate arithmetic the reservation -// uses, so the pre-call number and the reserved number are the same number. -func (r *Runner) terminologyEstimateUSD(msgs []llm.Message) float64 { - model, maxTokens := r.terminologyCallBudget(msgs) +// bankCallEstimateUSD projects ONE call's cost with the same price/estimate arithmetic the reservation uses, +// so the pre-call number and the reserved number are the same number. +func (r *Runner) bankCallEstimateUSD(model string, msgs []llm.Message) float64 { + _, maxTokens := r.bankCallBudget(model, msgs) promptEst := 0 for _, m := range msgs { promptEst += EstimateTokens(m.Content) @@ -389,37 +481,120 @@ func (r *Runner) terminologyEstimateUSD(msgs []llm.Message) float64 { r.Models.AdditiveReasoningTokens(model, "", 0)) } -// terminologyCheckpointExists reports whether THIS batch was already paid for in an earlier run. -func (r *Runner) terminologyCheckpointExists(st config.Stage, snapID string, ch chunk.Chunk, msgs []llm.Message) (bool, error) { - model, maxTokens := r.terminologyCallBudget(msgs) +// bankCheckpointExists reports whether THIS batch was already paid for in an earlier run, on the role's own +// request-hash axis. +func (r *Runner) bankCheckpointExists(role, model string, st config.Stage, snapID string, ch chunk.Chunk, msgs []llm.Message) (bool, error) { + _, maxTokens := r.bankCallBudget(model, msgs) cp, err := r.Store.GetCheckpoint(RequestHash(Request{ BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Attempt: 0, - Stage: st.Name, Role: roleTerminologist, Model: model, MaxTokens: maxTokens, SnapshotID: snapID, Messages: msgs, + Stage: st.Name, Role: role, Model: model, MaxTokens: maxTokens, SnapshotID: snapID, Messages: msgs, })) return cp != nil, err } -// runTerminologyAttempt performs ONE terminology call on the shared money path: reserve → call → -// settle+checkpoint, with a checkpoint hit replayed for free. It reuses runAttempt rather than -// re-implementing the money sequence — a second copy of that sequence is the drift this codebase already -// paid to remove once. isFinal=false: the reply is a term table, not shipping prose, so the output -// sanitizer must not judge it; the intrinsic classifier still runs and is a real guard (a refusal reply -// would otherwise be parsed as terminology). -func (r *Runner) runTerminologyAttempt(ctx context.Context, st config.Stage, snapID string, ch chunk.Chunk, +// runBankAttempt performs ONE bank-role call on the shared money path: reserve → call → settle+checkpoint, +// with a checkpoint hit replayed for free. It reuses runAttempt rather than re-implementing the money +// sequence — a second copy of that sequence is the drift this codebase already paid to remove once. +// isFinal=false: the reply is a term table, not shipping prose, so the output sanitizer must not judge it; +// the intrinsic classifier still runs and is a real guard (a refusal reply would otherwise be parsed as +// terminology). +func (r *Runner) runBankAttempt(ctx context.Context, role, model string, st config.Stage, snapID string, ch chunk.Chunk, job *store.Job, msgs []llm.Message) (stageAttempt, error) { - model, maxTokens := r.terminologyCallBudget(msgs) - tst := config.Stage{Name: st.Name, Role: roleTerminologist, Model: model} - // The log axis, exactly as runStage sets it for a chunk stage: without it every terminology line in a - // paid run reads "calling model deepseek-v4-flash" with no book, no role and no batch — unattributable - // in a multi-book log and unmatchable to the batch that produced a bad reply. Chunk carries the batch - // ordinal, which is what ch already addresses. + _, maxTokens := r.bankCallBudget(model, msgs) + tst := config.Stage{Name: st.Name, Role: role, Model: model} + // The log axis, exactly as runStage sets it for a chunk stage: without it every bank-role line in a paid + // run reads "calling model deepseek-v4-flash" with no book, no role and no batch — unattributable in a + // multi-book log and unmatchable to the batch that produced a bad reply. Chunk carries the batch ordinal. ri, _ := obs.ReqInfoFromContext(ctx) - ri.Book, ri.Chapter, ri.Chunk, ri.Stage, ri.Role = r.Book.BookID, ch.Chapter, ch.ChunkIdx, tst.Name, roleTerminologist + ri.Book, ri.Chapter, ri.Chunk, ri.Stage, ri.Role = r.Book.BookID, ch.Chapter, ch.ChunkIdx, tst.Name, role ctx = obs.WithReqInfo(ctx, ri) return r.runAttempt(ctx, tst, model, snapID, ch, job, 0, maxTokens, msgs, false, false) } +// bankRolePlan is one bank-level role's plan over a batch list: its cost axis (role/model), its own book-wide +// ceiling, and how it builds one batch's wire messages. The money sequence — estimate, budget-gate, +// checkpoint-or-call — is identical for the classifier and the terminologist; only the messages and the reply +// PARSING differ, and parsing is the caller's job on the returned texts. +type bankRolePlan struct { + role string + model string + budgetUSD float64 + messages func(batch []terminology.Candidate) ([]llm.Message, error) +} + +// bankRoleRun is what one role's pass produced: each batch's reply text (in batch order, "" for a batch the +// budget cut or that failed soft), plus the cost accounting for the report. +type bankRoleRun struct { + texts []string + estimateUSD float64 + costUSD float64 + cumUSD float64 + fresh bool +} + +// runBankRoleBatches runs plan over batches on the shared money path. The estimate is logged BEFORE any call; +// a budget ceiling or a soft denial stops the pass and leaves the remaining terms untouched — the run never +// aborts on this optional step. logKind names the phase (render|classify) in the logs. +func (r *Runner) runBankRoleBatches(ctx context.Context, snapID string, plan bankRolePlan, batches [][]terminology.Candidate, logKind string) (bankRoleRun, error) { + run := bankRoleRun{texts: make([]string, len(batches))} + msgsPer := make([][]llm.Message, len(batches)) + for i, b := range batches { + m, err := plan.messages(b) + if err != nil { + return run, err + } + msgsPer[i] = m + run.estimateUSD += r.bankCallEstimateUSD(plan.model, m) + } + r.Log.InfoContext(ctx, "terminology "+logKind+": estimate before any call", + "book", r.Book.BookID, "role", plan.role, "batches", len(batches), "model", plan.model, + "estimate_usd", fmt.Sprintf("%.6f", run.estimateUSD), "budget_usd", plan.budgetUSD, "version", terminologyVersion) + + spent, err := r.Store.RoleSpentUSD(r.Book.BookID, plan.role) + if err != nil { + return run, fmt.Errorf("pipeline: %s budget read: %w", plan.role, err) + } + st := config.Stage{Name: terminologyStageName, Role: plan.role, Model: plan.model} + job, err := r.Store.EnsureJob(r.Book.BookID, 0, terminologyStageName, snapID) + if err != nil { + return run, fmt.Errorf("pipeline: %s job: %w", plan.role, err) + } + for i := range batches { + // The synthetic chunk addresses the batch: chapter 0 is the BOOK level (no real chapter is 0), and the + // batch ordinal is the chunk index, so two batches can never collide on one checkpoint. + ch := chunk.Chunk{Chapter: 0, ChunkIdx: i} + paid, perr := r.bankCheckpointExists(plan.role, plan.model, st, snapID, ch, msgsPer[i]) + if perr != nil { + return run, perr + } + // The budget is checked BEFORE the call and against what the call would COST (the reservation's own + // upper bound), so it refuses on the conservative side and the config number is the bound it looks like. + if want := r.bankCallEstimateUSD(plan.model, msgsPer[i]); !paid && spent+want > plan.budgetUSD { + r.Log.WarnContext(ctx, "terminology "+logKind+": budget would be exceeded by the next batch; the remaining terms are left unchanged", + "book", r.Book.BookID, "role", plan.role, "spent_usd", fmt.Sprintf("%.6f", spent), + "next_batch_usd", fmt.Sprintf("%.6f", want), "budget_usd", plan.budgetUSD, "batches_left", len(batches)-i) + break + } + att, aerr := r.runBankAttempt(ctx, plan.role, plan.model, st, snapID, ch, job, msgsPer[i]) + if aerr != nil { + // A ceiling denial must not abort the book: this step is optional and the draft wave is already + // paid for. Degrade to "no change" exactly as the repair sub-step degrades. + if errors.Is(aerr, errReserveCeiling) { + r.Log.WarnContext(ctx, "terminology "+logKind+": call denied by a USD ceiling; remaining terms left unchanged", "book", r.Book.BookID, "role", plan.role) + break + } + return run, aerr + } + run.costUSD += att.runCost + run.cumUSD += att.cumCost + run.fresh = run.fresh || att.freshCall + spent += att.runCost + run.texts[i] = att.text + } + return run, nil +} + // attachConsolidatedDst stamps the terminologist's renderings onto the mined terms, which is what selects // the emission MODE in miner.DeltaYAML (§C2-7). Terms the role did not answer are left untouched. func attachConsolidatedDst(mined []miner.Term, consolidated map[string]string) []miner.Term { @@ -435,3 +610,20 @@ func attachConsolidatedDst(mined []miner.Term, consolidated map[string]string) [ } return out } + +// attachClassifiedType stamps the §2 classifier's corrected types onto the mined terms, so the BANKED type +// is the re-derived one, not the draft heuristic. It never changes which terms are in the delta — emission +// eligibility was already decided upstream by the miner — only the type recorded on the rows already there. +func attachClassifiedType(mined []miner.Term, classified map[string]string) []miner.Term { + if len(classified) == 0 { + return mined + } + out := make([]miner.Term, len(mined)) + copy(out, mined) + for i := range out { + if t := classified[text.NormalizeSourceKey(out[i].Src)]; t != "" { + out[i].Type = t + } + } + return out +} diff --git a/backend/internal/pipeline/testdata/golden/capture.golden b/backend/internal/pipeline/testdata/golden/capture.golden index 6de09ec8..7fdc2892 100644 --- a/backend/internal/pipeline/testdata/golden/capture.golden +++ b/backend/internal/pipeline/testdata/golden/capture.golden @@ -1,8 +1,8 @@ ==== run 1 (fresh) ==== -snapshot_draft: e66ae9aaac35d5f551473902e40fc93b5c55c123b39d5fccd426b30003def799 -snapshot_draft_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v6-generic-heading+srcabbrev+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v2-refusal+srcscript-echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v3-draft-gender+editor-src2dst+dc3-gender","embedded_version":"embed-v1-b0c8d2e685a2","memory_version":"bd451833d4ebb8a0ca07caf8deda76f027e1f42b78654987a22d215acf955eb7","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v6-target-tokenizer-combmark+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v7-hangul-leak"},"stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":6000}}]} -snapshot_edit: eba3eaa34c955bb190782fe28f490d50038757576aebaef6303b9b7b364fe6c5 -snapshot_edit_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v6-generic-heading+srcabbrev+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v2-refusal+srcscript-echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v3-draft-gender+editor-src2dst+dc3-gender","embedded_version":"embed-v1-b0c8d2e685a2","memory_version":"0a48d6ed9409de09f514af91aebdd622637621c9dc9464e8898c238791033d91","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v6-target-tokenizer-combmark+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v7-hangul-leak"},"stages":[{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}}]} +snapshot_draft: dc7572ca82f1898969220aecbb5727c36b9a42486ee70237ae8f95b8fe9ebce4 +snapshot_draft_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v6-generic-heading+srcabbrev+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v2-refusal+srcscript-echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v3-draft-gender+editor-src2dst+dc3-gender","embedded_version":"embed-v1-37bf9699835f","memory_version":"bd451833d4ebb8a0ca07caf8deda76f027e1f42b78654987a22d215acf955eb7","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v6-target-tokenizer-combmark+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v7-hangul-leak"},"stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":6000}}]} +snapshot_edit: 6799e44bbf02e21419447613ec3915f9f47c61f2746b94c1fc898ec9f5aa70bf +snapshot_edit_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v6-generic-heading+srcabbrev+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v2-refusal+srcscript-echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v3-draft-gender+editor-src2dst+dc3-gender","embedded_version":"embed-v1-37bf9699835f","memory_version":"0a48d6ed9409de09f514af91aebdd622637621c9dc9464e8898c238791033d91","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v6-target-tokenizer-combmark+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v7-hangul-leak"},"stages":[{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}}]} brief_hash: c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df memory_version: 0a48d6ed9409de09f514af91aebdd622637621c9dc9464e8898c238791033d91 base_memory_version: bd451833d4ebb8a0ca07caf8deda76f027e1f42b78654987a22d215acf955eb7 @@ -51,42 +51,42 @@ chunk ch8/0 disposition=flagged flag="sanitizer_stripped" final_text="Судзу stage=edit role=editor model=fake-model resume=false disp=flagged flag="sanitizer_stripped" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail="CJK leak in the ru output: 特产" stage_text="" recovered="Судзуки нашёл древний камень на каменном алтаре." -- chunk_status -- -ch1/0 draft snap_match=true content_hash=3e156226b9a74aa70c8a259c62e3d2fe68486a96d2178d31084c1e38a3256241 disp=ok flag="" attempts=1 final_hash=c359e411715aace7c3473339e1045cc2267d14e72166d074eca6d6829294756c cost=0.00182 escalated=false esc_model="" detail="" -ch1/0 edit snap_match=true content_hash=5e79dd5cbd310ac01d71ca258002beca18db715dfb8b1a08907508e506595355 disp=ok flag="" attempts=1 final_hash=4a75bc83c65ceb170c71a0f52424ebf247db0f8915890488010fa60def476cb3 cost=0.00182 escalated=false esc_model="" detail="" -ch1/1 draft snap_match=true content_hash=82fb7f985de6c07182e8c09a3ceaad94f2e63745b87024087e437fba762b8227 disp=ok flag="" attempts=1 final_hash=e12b4c9178b42841386fb9f41ade6324888b890ae0795829df0cbaa93d8f286f cost=0.00182 escalated=false esc_model="" detail="" -ch2/0 draft snap_match=true content_hash=cfab1e09264a3a30aba95e7da1d6a269468c6aa0c1b334d9241e78bc266a3571 disp=ok flag="" attempts=1 final_hash=c50b649e6b432bca99f7f9733521587dcf074ead85c0776bb3ff057cfbb306b5 cost=0.00728 escalated=true esc_model="fake-fallback" detail="" -ch2/0 edit snap_match=true content_hash=55ffd839a4a26c03cf57e08000ab2c84b267e1429ab4a8e212d3a01fda757632 disp=ok flag="" attempts=1 final_hash=5aec367f3096017ab4d46ee5ea776c0f5a6907f8d9c64e054bc54bc8d0485eb4 cost=0.00182 escalated=false esc_model="" detail="" +ch1/0 draft snap_match=true content_hash=3e156226b9a74aa70c8a259c62e3d2fe68486a96d2178d31084c1e38a3256241 disp=ok flag="" attempts=1 final_hash=70bca3400d2d8d848af27845cb2b86f362cdcf7a3a73e2611c46ec05feb7c4c2 cost=0.00182 escalated=false esc_model="" detail="" +ch1/0 edit snap_match=true content_hash=5e79dd5cbd310ac01d71ca258002beca18db715dfb8b1a08907508e506595355 disp=ok flag="" attempts=1 final_hash=799b6e6419910a2e52dd4652f2bde9bee59fbdb92a849f291ed33a5e30410a15 cost=0.00182 escalated=false esc_model="" detail="" +ch1/1 draft snap_match=true content_hash=82fb7f985de6c07182e8c09a3ceaad94f2e63745b87024087e437fba762b8227 disp=ok flag="" attempts=1 final_hash=ad47812002136f356e16d4fe4d71b73fdf1f2fbbe4f6a2cecd696abb07b7502d cost=0.00182 escalated=false esc_model="" detail="" +ch2/0 draft snap_match=true content_hash=cfab1e09264a3a30aba95e7da1d6a269468c6aa0c1b334d9241e78bc266a3571 disp=ok flag="" attempts=1 final_hash=35d5f692c908d3c15b40757a167c0deb3067e90492a4490500b0a0ccf359c9e2 cost=0.00728 escalated=true esc_model="fake-fallback" detail="" +ch2/0 edit snap_match=true content_hash=55ffd839a4a26c03cf57e08000ab2c84b267e1429ab4a8e212d3a01fda757632 disp=ok flag="" attempts=1 final_hash=e3f8acb903b0dd5798636597412acadfcf03a968ff049a8604b1142dea5cded1 cost=0.00182 escalated=false esc_model="" detail="" ch3/0 draft snap_match=true content_hash=21df44eb104900664bdaaeb2f716f51dcddbc81a71d845f6ba0f91c5bc4b2cfb disp=flagged flag="hard_refusal" attempts=1 final_hash= cost=0.00728 escalated=true esc_model="" detail="provider finish_reason=refusal" ch3/0 edit snap_match=true content_hash= disp=skipped flag="hard_refusal" attempts=0 final_hash= cost=0 escalated=false esc_model="" detail="skipped: a member draft chunk of this edit unit was flagged (hard_refusal)" -ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=1880b22b82a38a8a475a4d4d1457aa14c11d5669990ffaa1f0c311bf75ebf9c7 cost=0.00182 escalated=false esc_model="" detail="" -ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=a30dd9aebaed1fe56614e4d37e9e32959a6ace1a845a513ead6ca5a6797005c4 cost=0.00182 escalated=false esc_model="" detail="" -ch5/0 draft snap_match=true content_hash=2ed0a97f39cc5c57c62c3324f4646357372f26e3c5bae1773f24edf9449fc013 disp=ok flag="" attempts=1 final_hash=13b70142e64657da9c78d9d8da8f44de28b627e70823c34e800ebdba1dc5cc39 cost=0.00182 escalated=false esc_model="" detail="" -ch5/0 edit snap_match=true content_hash=b145d78e4d30a39cc4088a1ddb8d934234c65341c62c79545c2eff1e7ae0c3d3 disp=ok flag="" attempts=1 final_hash=cef18113a5c9f1386418ad27029e541c3ef3bf187032cd6f6debc936bbe40cc0 cost=0.00182 escalated=false esc_model="" detail="" -ch6/0 draft snap_match=true content_hash=5f396d299a7ec5b10f3c8591e54ce7d84c4d31b815e561b0dd5f56635aef5a71 disp=ok flag="" attempts=1 final_hash=fb16ddecec38450b712d9ec1e3435b150a588e382e7808b148653107566080b6 cost=0.00182 escalated=false esc_model="" detail="" +ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=c74a4b18be42dae5946fc9d33f1172e6be71a37bad7dc08076e5fba59316e858 cost=0.00182 escalated=false esc_model="" detail="" +ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=5f8f5737a4e02766f8500915341c8b8a8827f8a05b03d77a746a4a8bc48806eb cost=0.00182 escalated=false esc_model="" detail="" +ch5/0 draft snap_match=true content_hash=2ed0a97f39cc5c57c62c3324f4646357372f26e3c5bae1773f24edf9449fc013 disp=ok flag="" attempts=1 final_hash=407f023c09e54b0399f79cf59e893817065b28129a807c57b25cc796202d75e1 cost=0.00182 escalated=false esc_model="" detail="" +ch5/0 edit snap_match=true content_hash=b145d78e4d30a39cc4088a1ddb8d934234c65341c62c79545c2eff1e7ae0c3d3 disp=ok flag="" attempts=1 final_hash=2b722376f3691a64c38d9a8026f567dfce0dd0a5ca3d2355bd2a95e4807ee67c cost=0.00182 escalated=false esc_model="" detail="" +ch6/0 draft snap_match=true content_hash=5f396d299a7ec5b10f3c8591e54ce7d84c4d31b815e561b0dd5f56635aef5a71 disp=ok flag="" attempts=1 final_hash=afd2169fce4c9aaecdf7f3de1f1f87bc08d5b672d19bc1dc772991f8d3321d60 cost=0.00182 escalated=false esc_model="" detail="" ch6/0 edit snap_match=true content_hash=592353321cc8000e792e0897446e4dd4f88a0280b9368b62cbd5c82b0c433193 disp=flagged flag="sanitizer_defect" attempts=1 final_hash= cost=0.00182 escalated=false esc_model="" detail="leading service preamble: Вот перевод фрагмента:" -ch7/0 draft snap_match=true content_hash=15c3ebbd56bb19880202d35b0206357655da245343376492f7ceb61808c8a5be disp=ok flag="" attempts=1 final_hash=09fdfda22e00061376cef8a50eb005c7b154b10f9808ed12ab2b1d5a54012865 cost=0.00182 escalated=false esc_model="" detail="" -ch7/0 edit snap_match=true content_hash=0b2674ed9829b2e6526ff41a60dd280b6670c539476526bbe835d9bf7a6be4c4 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:93c49c0115fab1b8ac52b94bf513b2c119a7dc44f7fa50b59c8a8a2843a0f373 cost=0.00182 escalated=false esc_model="" detail="markdown header in the output: ### Глава 7" -ch8/0 draft snap_match=true content_hash=4a420977cc34ce738b4978e6a1baf45d78d773a135070c3b7f8c723adfa57d7b disp=ok flag="" attempts=1 final_hash=c93524c990432bbc36a9866e2a22e9e1ea5b6e6a6bf08b282bde0d79a6bb3f36 cost=0.00182 escalated=false esc_model="" detail="" -ch8/0 edit snap_match=true content_hash=c851100c8d6785f77d68f8127472790ae9d0408b7fec44785d3ff9d46842c6f8 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:67de3e29186115d82cbc899002b3e70439f7d85f3794906d3b32bce9ab8b4aea cost=0.00182 escalated=false esc_model="" detail="CJK leak in the ru output: 特产" +ch7/0 draft snap_match=true content_hash=15c3ebbd56bb19880202d35b0206357655da245343376492f7ceb61808c8a5be disp=ok flag="" attempts=1 final_hash=486facff54a77c86f44cd322200caf4cf982975eeda552fa6dfdee4aa8df0904 cost=0.00182 escalated=false esc_model="" detail="" +ch7/0 edit snap_match=true content_hash=0b2674ed9829b2e6526ff41a60dd280b6670c539476526bbe835d9bf7a6be4c4 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:def877b81bcdc52e2525fdcaae04b8c64f632c7d6747b31df7f4c7f1ad176e73 cost=0.00182 escalated=false esc_model="" detail="markdown header in the output: ### Глава 7" +ch8/0 draft snap_match=true content_hash=4a420977cc34ce738b4978e6a1baf45d78d773a135070c3b7f8c723adfa57d7b disp=ok flag="" attempts=1 final_hash=070d13df73e028346b69abe69253a999f109d92b01f39f3dfada0772be50e185 cost=0.00182 escalated=false esc_model="" detail="" +ch8/0 edit snap_match=true content_hash=c851100c8d6785f77d68f8127472790ae9d0408b7fec44785d3ff9d46842c6f8 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:5095da45458381184b8798287c934e133c39668fed86c50cce921a1cb7df7893 cost=0.00182 escalated=false esc_model="" detail="CJK leak in the ru output: 特产" -- request_log (insertion order) -- -ch1/0 draft role=translator req=fake-model actual=fake-model hash=c359e411715aace7c3473339e1045cc2267d14e72166d074eca6d6829294756c tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/1 draft role=translator req=fake-model actual=fake-model hash=e12b4c9178b42841386fb9f41ade6324888b890ae0795829df0cbaa93d8f286f tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 draft role=translator req=fake-model actual=fake-model hash=ff97014d98b89f2b82696792cd28e903d0017ae0a95b0efde2b523f8d9579bc5 tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=c50b649e6b432bca99f7f9733521587dcf074ead85c0776bb3ff057cfbb306b5 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" -ch3/0 draft role=translator req=fake-model actual=fake-model hash=195edb6c4c2762b4645071493007c4db83969b3b0af41095a69e40bbf030830f tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=365cad742ce4ecaa1af247b74c4bf1c04da6ab15565aae9285442f8ee7011882 tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" -ch4/0 draft role=translator req=fake-model actual=fake-model hash=1880b22b82a38a8a475a4d4d1457aa14c11d5669990ffaa1f0c311bf75ebf9c7 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch5/0 draft role=translator req=fake-model actual=fake-model hash=13b70142e64657da9c78d9d8da8f44de28b627e70823c34e800ebdba1dc5cc39 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch6/0 draft role=translator req=fake-model actual=fake-model hash=fb16ddecec38450b712d9ec1e3435b150a588e382e7808b148653107566080b6 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch7/0 draft role=translator req=fake-model actual=fake-model hash=09fdfda22e00061376cef8a50eb005c7b154b10f9808ed12ab2b1d5a54012865 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch8/0 draft role=translator req=fake-model actual=fake-model hash=c93524c990432bbc36a9866e2a22e9e1ea5b6e6a6bf08b282bde0d79a6bb3f36 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/0 edit role=editor req=fake-model actual=fake-model hash=4a75bc83c65ceb170c71a0f52424ebf247db0f8915890488010fa60def476cb3 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 edit role=editor req=fake-model actual=fake-model hash=5aec367f3096017ab4d46ee5ea776c0f5a6907f8d9c64e054bc54bc8d0485eb4 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch4/0 edit role=editor req=fake-model actual=fake-model hash=a30dd9aebaed1fe56614e4d37e9e32959a6ace1a845a513ead6ca5a6797005c4 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch5/0 edit role=editor req=fake-model actual=fake-model hash=cef18113a5c9f1386418ad27029e541c3ef3bf187032cd6f6debc936bbe40cc0 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch6/0 edit role=editor req=fake-model actual=fake-model hash=0b684110fc1d2557ce1a8ad696146e52ea2b3dce1f409b408acc40e4ee6ea7cb tm_hit=0 ok=0 finish="stop" degraded="sanitizer_defect" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch7/0 edit role=editor req=fake-model actual=fake-model hash=d2de4948d9933fc2fbaa05f4f66f030fd6f0b245bd338ed6e81a598d55705fee tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch8/0 edit role=editor req=fake-model actual=fake-model hash=d29763de86c743a4c8c4d3963705a82ada3db29220c4953c0c990446f48e86d1 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/0 draft role=translator req=fake-model actual=fake-model hash=70bca3400d2d8d848af27845cb2b86f362cdcf7a3a73e2611c46ec05feb7c4c2 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/1 draft role=translator req=fake-model actual=fake-model hash=ad47812002136f356e16d4fe4d71b73fdf1f2fbbe4f6a2cecd696abb07b7502d tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 draft role=translator req=fake-model actual=fake-model hash=56b4390260a43c1ffd2ba17afb8c3fab16113992856bb960e62e11c4937e0028 tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=35d5f692c908d3c15b40757a167c0deb3067e90492a4490500b0a0ccf359c9e2 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" +ch3/0 draft role=translator req=fake-model actual=fake-model hash=9e6325591eca8bc28e34be1da64106704d9589193c991f6545b22e8ddbae3b54 tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=544c921f47e59718019cfff42782f590deba18352123ae0687ceade0130fe2bd tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" +ch4/0 draft role=translator req=fake-model actual=fake-model hash=c74a4b18be42dae5946fc9d33f1172e6be71a37bad7dc08076e5fba59316e858 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch5/0 draft role=translator req=fake-model actual=fake-model hash=407f023c09e54b0399f79cf59e893817065b28129a807c57b25cc796202d75e1 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch6/0 draft role=translator req=fake-model actual=fake-model hash=afd2169fce4c9aaecdf7f3de1f1f87bc08d5b672d19bc1dc772991f8d3321d60 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch7/0 draft role=translator req=fake-model actual=fake-model hash=486facff54a77c86f44cd322200caf4cf982975eeda552fa6dfdee4aa8df0904 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch8/0 draft role=translator req=fake-model actual=fake-model hash=070d13df73e028346b69abe69253a999f109d92b01f39f3dfada0772be50e185 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/0 edit role=editor req=fake-model actual=fake-model hash=799b6e6419910a2e52dd4652f2bde9bee59fbdb92a849f291ed33a5e30410a15 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 edit role=editor req=fake-model actual=fake-model hash=e3f8acb903b0dd5798636597412acadfcf03a968ff049a8604b1142dea5cded1 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch4/0 edit role=editor req=fake-model actual=fake-model hash=5f8f5737a4e02766f8500915341c8b8a8827f8a05b03d77a746a4a8bc48806eb tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch5/0 edit role=editor req=fake-model actual=fake-model hash=2b722376f3691a64c38d9a8026f567dfce0dd0a5ca3d2355bd2a95e4807ee67c tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch6/0 edit role=editor req=fake-model actual=fake-model hash=806e0871ca091d6ad598412d1ed78c711c0bc19412b1c20599d25ea1a7d3dc0f tm_hit=0 ok=0 finish="stop" degraded="sanitizer_defect" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch7/0 edit role=editor req=fake-model actual=fake-model hash=c445cf04d138dd1ecfae1b805336f2c5cde333b575b64f75c10f1017e39ac498 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch8/0 edit role=editor req=fake-model actual=fake-model hash=1a3bc5b91428e2cc31a965748ea10046bf6d9f420f8c8d3e79f9fd3da5b03f4f tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" -- retrieval_state -- ch1/0 snap_match=true exact=3 sticky=0 ambiguous=0 spoiler=1 evicted=0 postcheck_miss=1 style_flags=0 trust_gated=0 injected_ids=["紋章\u001f\u001f0\u001f0","鈴木\u001f\u001f0\u001f0","魔法学院\u001f\u001f0\u001f0"] postcheck_detail=[{"src":"紋章","dst":"герб","disp":"confirmed"}] style_detail= trust_gate_detail= @@ -126,10 +126,10 @@ ch8/0 snap_match=true exact=1 sticky=0 ambiguous=0 spoiler=0 evicted=0 postcheck [16] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 鈴木 → «Судзуки» (муж. — мужские родовые формы)"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 9e65df37b751. МАРКДАУН-СЦЕНА Судзуки открыл седьмую дверь."}],"model":"fake-model","stream":false,"temperature":0.4} [17] {"max_tokens":4000,"messages":[{"role":"system","content":"Ты — монолингвальный редактор русского текста. Правь стиль, не меняя смысла. Книга: Золотая книга."},{"role":"system","content":"КАНОНИЧЕСКИЕ ПЕРЕВОДЫ имён и терминов (в черновике термин исходника слева ДОЛЖЕН быть передан именно указанной формой справа — приводи к ней любые расхождения, склоняя по контексту; не вводи иных вариантов и не меняй ничего другого):\n- 鈴木 → «Судзуки» (муж. — мужские родовые формы)"},{"role":"user","content":"Черновик перевода для редактуры: ЧЕРНОВИК ПЕРЕВОДА 2c858e3366d2. ИЕРОГЛИФ-СЦЕНА Судзуки нашёл камень."}],"model":"fake-model","stream":false,"temperature":0.4} ==== run 2 (resume) ==== -snapshot_draft: e66ae9aaac35d5f551473902e40fc93b5c55c123b39d5fccd426b30003def799 -snapshot_draft_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v6-generic-heading+srcabbrev+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v2-refusal+srcscript-echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v3-draft-gender+editor-src2dst+dc3-gender","embedded_version":"embed-v1-b0c8d2e685a2","memory_version":"bd451833d4ebb8a0ca07caf8deda76f027e1f42b78654987a22d215acf955eb7","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v6-target-tokenizer-combmark+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v7-hangul-leak"},"stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":6000}}]} -snapshot_edit: eba3eaa34c955bb190782fe28f490d50038757576aebaef6303b9b7b364fe6c5 -snapshot_edit_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v6-generic-heading+srcabbrev+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v2-refusal+srcscript-echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v3-draft-gender+editor-src2dst+dc3-gender","embedded_version":"embed-v1-b0c8d2e685a2","memory_version":"0a48d6ed9409de09f514af91aebdd622637621c9dc9464e8898c238791033d91","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v6-target-tokenizer-combmark+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v7-hangul-leak"},"stages":[{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}}]} +snapshot_draft: dc7572ca82f1898969220aecbb5727c36b9a42486ee70237ae8f95b8fe9ebce4 +snapshot_draft_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v6-generic-heading+srcabbrev+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v2-refusal+srcscript-echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v3-draft-gender+editor-src2dst+dc3-gender","embedded_version":"embed-v1-37bf9699835f","memory_version":"bd451833d4ebb8a0ca07caf8deda76f027e1f42b78654987a22d215acf955eb7","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v6-target-tokenizer-combmark+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v7-hangul-leak"},"stages":[{"name":"draft","role":"translator","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"26d0245a56d180eafd200ebe5311aab2488142c4a98dcddee21ea457814aa21a","temperature":0.3,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000},"escalate_to":"fake-fallback","escalate_capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":6000}}]} +snapshot_edit: 6799e44bbf02e21419447613ec3915f9f47c61f2746b94c1fc898ec9f5aa70bf +snapshot_edit_payload: {"brief_hash":"c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df","chunker_version":"chunker-v6-generic-heading+srcabbrev+u15.0.0","estimator_version":"estimator-v0+u15.0.0","max_tokens_policy":"maxtok-v1-double-per-attempt","classifier_version":"classify-v2-refusal+srcscript-echo015+loop+u15.0.0","pipeline_core":"C1","max_output_ratio":2,"min_max_tokens":512,"context_assembly":{"glossary_injection":"selective","glossary_token_budget":800,"cache_ttl":""},"segmentation":{"draft_budget_out":1797,"edit_ceiling_out":3200,"fertility_cjk":1.1978,"fertility_other":0.3852},"render_format_version":"renderfmt-v3-draft-gender+editor-src2dst+dc3-gender","embedded_version":"embed-v1-37bf9699835f","memory_version":"0a48d6ed9409de09f514af91aebdd622637621c9dc9464e8898c238791033d91","postcheck_gate":false,"coverage":{"enabled":false},"style_check_version":"cheapgate-v6-target-tokenizer-combmark+u15.0.0","sanitizer":{"enabled":true,"version":"sanitizer-v7-hangul-leak"},"stages":[{"name":"edit","role":"editor","model":"fake-model","prompt_version":"v-golden","prompt_sha256":"6a8e7f71139d6bc09a95159d1cb895e05b56d2f2f7417c5e20a7073c517c79f6","temperature":0.4,"reasoning":"off","capability":{"Budget":"max_tokens","Temp":"send","TempValue":0,"Reasoning":{"Control":"none","OffEffort":"","OffExtraBody":null,"OnExtraBody":null},"MinMaxTokens":4000}}]} brief_hash: c2021b1b5e6c20f6084e425b9a70bdc5f682801be347a57eb1c591deb8cab0df memory_version: 0a48d6ed9409de09f514af91aebdd622637621c9dc9464e8898c238791033d91 base_memory_version: bd451833d4ebb8a0ca07caf8deda76f027e1f42b78654987a22d215acf955eb7 @@ -178,58 +178,58 @@ chunk ch8/0 disposition=flagged flag="sanitizer_stripped" final_text="Судзу stage=edit role=editor model=fake-model resume=true disp=flagged flag="sanitizer_stripped" attempts=1 escalated=false esc_model="" finish="sanitized_export" cum_usd=0.00182 detail="CJK leak in the ru output: 特产" stage_text="" recovered="Судзуки нашёл древний камень на каменном алтаре." -- chunk_status -- -ch1/0 draft snap_match=true content_hash=3e156226b9a74aa70c8a259c62e3d2fe68486a96d2178d31084c1e38a3256241 disp=ok flag="" attempts=1 final_hash=c359e411715aace7c3473339e1045cc2267d14e72166d074eca6d6829294756c cost=0.00182 escalated=false esc_model="" detail="" -ch1/0 edit snap_match=true content_hash=5e79dd5cbd310ac01d71ca258002beca18db715dfb8b1a08907508e506595355 disp=ok flag="" attempts=1 final_hash=4a75bc83c65ceb170c71a0f52424ebf247db0f8915890488010fa60def476cb3 cost=0.00182 escalated=false esc_model="" detail="" -ch1/1 draft snap_match=true content_hash=82fb7f985de6c07182e8c09a3ceaad94f2e63745b87024087e437fba762b8227 disp=ok flag="" attempts=1 final_hash=e12b4c9178b42841386fb9f41ade6324888b890ae0795829df0cbaa93d8f286f cost=0.00182 escalated=false esc_model="" detail="" -ch2/0 draft snap_match=true content_hash=cfab1e09264a3a30aba95e7da1d6a269468c6aa0c1b334d9241e78bc266a3571 disp=ok flag="" attempts=1 final_hash=c50b649e6b432bca99f7f9733521587dcf074ead85c0776bb3ff057cfbb306b5 cost=0.00728 escalated=true esc_model="fake-fallback" detail="" -ch2/0 edit snap_match=true content_hash=55ffd839a4a26c03cf57e08000ab2c84b267e1429ab4a8e212d3a01fda757632 disp=ok flag="" attempts=1 final_hash=5aec367f3096017ab4d46ee5ea776c0f5a6907f8d9c64e054bc54bc8d0485eb4 cost=0.00182 escalated=false esc_model="" detail="" +ch1/0 draft snap_match=true content_hash=3e156226b9a74aa70c8a259c62e3d2fe68486a96d2178d31084c1e38a3256241 disp=ok flag="" attempts=1 final_hash=70bca3400d2d8d848af27845cb2b86f362cdcf7a3a73e2611c46ec05feb7c4c2 cost=0.00182 escalated=false esc_model="" detail="" +ch1/0 edit snap_match=true content_hash=5e79dd5cbd310ac01d71ca258002beca18db715dfb8b1a08907508e506595355 disp=ok flag="" attempts=1 final_hash=799b6e6419910a2e52dd4652f2bde9bee59fbdb92a849f291ed33a5e30410a15 cost=0.00182 escalated=false esc_model="" detail="" +ch1/1 draft snap_match=true content_hash=82fb7f985de6c07182e8c09a3ceaad94f2e63745b87024087e437fba762b8227 disp=ok flag="" attempts=1 final_hash=ad47812002136f356e16d4fe4d71b73fdf1f2fbbe4f6a2cecd696abb07b7502d cost=0.00182 escalated=false esc_model="" detail="" +ch2/0 draft snap_match=true content_hash=cfab1e09264a3a30aba95e7da1d6a269468c6aa0c1b334d9241e78bc266a3571 disp=ok flag="" attempts=1 final_hash=35d5f692c908d3c15b40757a167c0deb3067e90492a4490500b0a0ccf359c9e2 cost=0.00728 escalated=true esc_model="fake-fallback" detail="" +ch2/0 edit snap_match=true content_hash=55ffd839a4a26c03cf57e08000ab2c84b267e1429ab4a8e212d3a01fda757632 disp=ok flag="" attempts=1 final_hash=e3f8acb903b0dd5798636597412acadfcf03a968ff049a8604b1142dea5cded1 cost=0.00182 escalated=false esc_model="" detail="" ch3/0 draft snap_match=true content_hash=21df44eb104900664bdaaeb2f716f51dcddbc81a71d845f6ba0f91c5bc4b2cfb disp=flagged flag="hard_refusal" attempts=1 final_hash= cost=0.00728 escalated=true esc_model="" detail="provider finish_reason=refusal" ch3/0 edit snap_match=true content_hash= disp=skipped flag="hard_refusal" attempts=0 final_hash= cost=0 escalated=false esc_model="" detail="skipped: a member draft chunk of this edit unit was flagged (hard_refusal)" -ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=1880b22b82a38a8a475a4d4d1457aa14c11d5669990ffaa1f0c311bf75ebf9c7 cost=0.00182 escalated=false esc_model="" detail="" -ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=a30dd9aebaed1fe56614e4d37e9e32959a6ace1a845a513ead6ca5a6797005c4 cost=0.00182 escalated=false esc_model="" detail="" -ch5/0 draft snap_match=true content_hash=2ed0a97f39cc5c57c62c3324f4646357372f26e3c5bae1773f24edf9449fc013 disp=ok flag="" attempts=1 final_hash=13b70142e64657da9c78d9d8da8f44de28b627e70823c34e800ebdba1dc5cc39 cost=0.00182 escalated=false esc_model="" detail="" -ch5/0 edit snap_match=true content_hash=b145d78e4d30a39cc4088a1ddb8d934234c65341c62c79545c2eff1e7ae0c3d3 disp=ok flag="" attempts=1 final_hash=cef18113a5c9f1386418ad27029e541c3ef3bf187032cd6f6debc936bbe40cc0 cost=0.00182 escalated=false esc_model="" detail="" -ch6/0 draft snap_match=true content_hash=5f396d299a7ec5b10f3c8591e54ce7d84c4d31b815e561b0dd5f56635aef5a71 disp=ok flag="" attempts=1 final_hash=fb16ddecec38450b712d9ec1e3435b150a588e382e7808b148653107566080b6 cost=0.00182 escalated=false esc_model="" detail="" +ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=c74a4b18be42dae5946fc9d33f1172e6be71a37bad7dc08076e5fba59316e858 cost=0.00182 escalated=false esc_model="" detail="" +ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=5f8f5737a4e02766f8500915341c8b8a8827f8a05b03d77a746a4a8bc48806eb cost=0.00182 escalated=false esc_model="" detail="" +ch5/0 draft snap_match=true content_hash=2ed0a97f39cc5c57c62c3324f4646357372f26e3c5bae1773f24edf9449fc013 disp=ok flag="" attempts=1 final_hash=407f023c09e54b0399f79cf59e893817065b28129a807c57b25cc796202d75e1 cost=0.00182 escalated=false esc_model="" detail="" +ch5/0 edit snap_match=true content_hash=b145d78e4d30a39cc4088a1ddb8d934234c65341c62c79545c2eff1e7ae0c3d3 disp=ok flag="" attempts=1 final_hash=2b722376f3691a64c38d9a8026f567dfce0dd0a5ca3d2355bd2a95e4807ee67c cost=0.00182 escalated=false esc_model="" detail="" +ch6/0 draft snap_match=true content_hash=5f396d299a7ec5b10f3c8591e54ce7d84c4d31b815e561b0dd5f56635aef5a71 disp=ok flag="" attempts=1 final_hash=afd2169fce4c9aaecdf7f3de1f1f87bc08d5b672d19bc1dc772991f8d3321d60 cost=0.00182 escalated=false esc_model="" detail="" ch6/0 edit snap_match=true content_hash=592353321cc8000e792e0897446e4dd4f88a0280b9368b62cbd5c82b0c433193 disp=flagged flag="sanitizer_defect" attempts=1 final_hash= cost=0.00182 escalated=false esc_model="" detail="leading service preamble: Вот перевод фрагмента:" -ch7/0 draft snap_match=true content_hash=15c3ebbd56bb19880202d35b0206357655da245343376492f7ceb61808c8a5be disp=ok flag="" attempts=1 final_hash=09fdfda22e00061376cef8a50eb005c7b154b10f9808ed12ab2b1d5a54012865 cost=0.00182 escalated=false esc_model="" detail="" -ch7/0 edit snap_match=true content_hash=0b2674ed9829b2e6526ff41a60dd280b6670c539476526bbe835d9bf7a6be4c4 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:93c49c0115fab1b8ac52b94bf513b2c119a7dc44f7fa50b59c8a8a2843a0f373 cost=0.00182 escalated=false esc_model="" detail="markdown header in the output: ### Глава 7" -ch8/0 draft snap_match=true content_hash=4a420977cc34ce738b4978e6a1baf45d78d773a135070c3b7f8c723adfa57d7b disp=ok flag="" attempts=1 final_hash=c93524c990432bbc36a9866e2a22e9e1ea5b6e6a6bf08b282bde0d79a6bb3f36 cost=0.00182 escalated=false esc_model="" detail="" -ch8/0 edit snap_match=true content_hash=c851100c8d6785f77d68f8127472790ae9d0408b7fec44785d3ff9d46842c6f8 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:67de3e29186115d82cbc899002b3e70439f7d85f3794906d3b32bce9ab8b4aea cost=0.00182 escalated=false esc_model="" detail="CJK leak in the ru output: 特产" +ch7/0 draft snap_match=true content_hash=15c3ebbd56bb19880202d35b0206357655da245343376492f7ceb61808c8a5be disp=ok flag="" attempts=1 final_hash=486facff54a77c86f44cd322200caf4cf982975eeda552fa6dfdee4aa8df0904 cost=0.00182 escalated=false esc_model="" detail="" +ch7/0 edit snap_match=true content_hash=0b2674ed9829b2e6526ff41a60dd280b6670c539476526bbe835d9bf7a6be4c4 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:def877b81bcdc52e2525fdcaae04b8c64f632c7d6747b31df7f4c7f1ad176e73 cost=0.00182 escalated=false esc_model="" detail="markdown header in the output: ### Глава 7" +ch8/0 draft snap_match=true content_hash=4a420977cc34ce738b4978e6a1baf45d78d773a135070c3b7f8c723adfa57d7b disp=ok flag="" attempts=1 final_hash=070d13df73e028346b69abe69253a999f109d92b01f39f3dfada0772be50e185 cost=0.00182 escalated=false esc_model="" detail="" +ch8/0 edit snap_match=true content_hash=c851100c8d6785f77d68f8127472790ae9d0408b7fec44785d3ff9d46842c6f8 disp=flagged flag="sanitizer_stripped" attempts=1 final_hash=tm-sanitized-v1:5095da45458381184b8798287c934e133c39668fed86c50cce921a1cb7df7893 cost=0.00182 escalated=false esc_model="" detail="CJK leak in the ru output: 特产" -- request_log (insertion order) -- -ch1/0 draft role=translator req=fake-model actual=fake-model hash=c359e411715aace7c3473339e1045cc2267d14e72166d074eca6d6829294756c tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/1 draft role=translator req=fake-model actual=fake-model hash=e12b4c9178b42841386fb9f41ade6324888b890ae0795829df0cbaa93d8f286f tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 draft role=translator req=fake-model actual=fake-model hash=ff97014d98b89f2b82696792cd28e903d0017ae0a95b0efde2b523f8d9579bc5 tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=c50b649e6b432bca99f7f9733521587dcf074ead85c0776bb3ff057cfbb306b5 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" -ch3/0 draft role=translator req=fake-model actual=fake-model hash=195edb6c4c2762b4645071493007c4db83969b3b0af41095a69e40bbf030830f tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=365cad742ce4ecaa1af247b74c4bf1c04da6ab15565aae9285442f8ee7011882 tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" -ch4/0 draft role=translator req=fake-model actual=fake-model hash=1880b22b82a38a8a475a4d4d1457aa14c11d5669990ffaa1f0c311bf75ebf9c7 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch5/0 draft role=translator req=fake-model actual=fake-model hash=13b70142e64657da9c78d9d8da8f44de28b627e70823c34e800ebdba1dc5cc39 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch6/0 draft role=translator req=fake-model actual=fake-model hash=fb16ddecec38450b712d9ec1e3435b150a588e382e7808b148653107566080b6 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch7/0 draft role=translator req=fake-model actual=fake-model hash=09fdfda22e00061376cef8a50eb005c7b154b10f9808ed12ab2b1d5a54012865 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch8/0 draft role=translator req=fake-model actual=fake-model hash=c93524c990432bbc36a9866e2a22e9e1ea5b6e6a6bf08b282bde0d79a6bb3f36 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/0 edit role=editor req=fake-model actual=fake-model hash=4a75bc83c65ceb170c71a0f52424ebf247db0f8915890488010fa60def476cb3 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch2/0 edit role=editor req=fake-model actual=fake-model hash=5aec367f3096017ab4d46ee5ea776c0f5a6907f8d9c64e054bc54bc8d0485eb4 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch4/0 edit role=editor req=fake-model actual=fake-model hash=a30dd9aebaed1fe56614e4d37e9e32959a6ace1a845a513ead6ca5a6797005c4 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch5/0 edit role=editor req=fake-model actual=fake-model hash=cef18113a5c9f1386418ad27029e541c3ef3bf187032cd6f6debc936bbe40cc0 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch6/0 edit role=editor req=fake-model actual=fake-model hash=0b684110fc1d2557ce1a8ad696146e52ea2b3dce1f409b408acc40e4ee6ea7cb tm_hit=0 ok=0 finish="stop" degraded="sanitizer_defect" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch7/0 edit role=editor req=fake-model actual=fake-model hash=d2de4948d9933fc2fbaa05f4f66f030fd6f0b245bd338ed6e81a598d55705fee tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch8/0 edit role=editor req=fake-model actual=fake-model hash=d29763de86c743a4c8c4d3963705a82ada3db29220c4953c0c990446f48e86d1 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" -ch1/0 draft role=translator req=fake-model actual=fake-model hash=c359e411715aace7c3473339e1045cc2267d14e72166d074eca6d6829294756c tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch1/1 draft role=translator req=fake-model actual=fake-model hash=e12b4c9178b42841386fb9f41ade6324888b890ae0795829df0cbaa93d8f286f tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch2/0 draft role=translator req=fake-model actual=fake-fallback hash=c50b649e6b432bca99f7f9733521587dcf074ead85c0776bb3ff057cfbb306b5 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch1/0 draft role=translator req=fake-model actual=fake-model hash=70bca3400d2d8d848af27845cb2b86f362cdcf7a3a73e2611c46ec05feb7c4c2 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/1 draft role=translator req=fake-model actual=fake-model hash=ad47812002136f356e16d4fe4d71b73fdf1f2fbbe4f6a2cecd696abb07b7502d tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 draft role=translator req=fake-model actual=fake-model hash=56b4390260a43c1ffd2ba17afb8c3fab16113992856bb960e62e11c4937e0028 tm_hit=0 ok=0 finish="stop" degraded="cjk_artifact" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 draft role=translator req=fake-fallback actual=fake-fallback hash=35d5f692c908d3c15b40757a167c0deb3067e90492a4490500b0a0ccf359c9e2 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" +ch3/0 draft role=translator req=fake-model actual=fake-model hash=9e6325591eca8bc28e34be1da64106704d9589193c991f6545b22e8ddbae3b54 tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch3/0 draft role=translator req=fake-fallback actual=fake-fallback hash=544c921f47e59718019cfff42782f590deba18352123ae0687ceade0130fe2bd tm_hit=0 ok=0 finish="refusal" degraded="hard_refusal" cost=0.0054600000000000004 tokens=1000/200/0/500/0 err="" +ch4/0 draft role=translator req=fake-model actual=fake-model hash=c74a4b18be42dae5946fc9d33f1172e6be71a37bad7dc08076e5fba59316e858 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch5/0 draft role=translator req=fake-model actual=fake-model hash=407f023c09e54b0399f79cf59e893817065b28129a807c57b25cc796202d75e1 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch6/0 draft role=translator req=fake-model actual=fake-model hash=afd2169fce4c9aaecdf7f3de1f1f87bc08d5b672d19bc1dc772991f8d3321d60 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch7/0 draft role=translator req=fake-model actual=fake-model hash=486facff54a77c86f44cd322200caf4cf982975eeda552fa6dfdee4aa8df0904 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch8/0 draft role=translator req=fake-model actual=fake-model hash=070d13df73e028346b69abe69253a999f109d92b01f39f3dfada0772be50e185 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/0 edit role=editor req=fake-model actual=fake-model hash=799b6e6419910a2e52dd4652f2bde9bee59fbdb92a849f291ed33a5e30410a15 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch2/0 edit role=editor req=fake-model actual=fake-model hash=e3f8acb903b0dd5798636597412acadfcf03a968ff049a8604b1142dea5cded1 tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch4/0 edit role=editor req=fake-model actual=fake-model hash=5f8f5737a4e02766f8500915341c8b8a8827f8a05b03d77a746a4a8bc48806eb tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch5/0 edit role=editor req=fake-model actual=fake-model hash=2b722376f3691a64c38d9a8026f567dfce0dd0a5ca3d2355bd2a95e4807ee67c tm_hit=0 ok=1 finish="stop" degraded="" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch6/0 edit role=editor req=fake-model actual=fake-model hash=806e0871ca091d6ad598412d1ed78c711c0bc19412b1c20599d25ea1a7d3dc0f tm_hit=0 ok=0 finish="stop" degraded="sanitizer_defect" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch7/0 edit role=editor req=fake-model actual=fake-model hash=c445cf04d138dd1ecfae1b805336f2c5cde333b575b64f75c10f1017e39ac498 tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch8/0 edit role=editor req=fake-model actual=fake-model hash=1a3bc5b91428e2cc31a965748ea10046bf6d9f420f8c8d3e79f9fd3da5b03f4f tm_hit=0 ok=0 finish="stop" degraded="sanitizer_stripped" cost=0.00182 tokens=1000/200/0/500/0 err="" +ch1/0 draft role=translator req=fake-model actual=fake-model hash=70bca3400d2d8d848af27845cb2b86f362cdcf7a3a73e2611c46ec05feb7c4c2 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch1/1 draft role=translator req=fake-model actual=fake-model hash=ad47812002136f356e16d4fe4d71b73fdf1f2fbbe4f6a2cecd696abb07b7502d tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch2/0 draft role=translator req=fake-model actual=fake-fallback hash=35d5f692c908d3c15b40757a167c0deb3067e90492a4490500b0a0ccf359c9e2 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" ch3/0 draft role=translator req=fake-model actual=fake-model hash= tm_hit=1 ok=0 finish="" degraded="hard_refusal" cost=0 tokens=0/0/0/0/0 err="" -ch4/0 draft role=translator req=fake-model actual=fake-model hash=1880b22b82a38a8a475a4d4d1457aa14c11d5669990ffaa1f0c311bf75ebf9c7 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch5/0 draft role=translator req=fake-model actual=fake-model hash=13b70142e64657da9c78d9d8da8f44de28b627e70823c34e800ebdba1dc5cc39 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch6/0 draft role=translator req=fake-model actual=fake-model hash=fb16ddecec38450b712d9ec1e3435b150a588e382e7808b148653107566080b6 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch7/0 draft role=translator req=fake-model actual=fake-model hash=09fdfda22e00061376cef8a50eb005c7b154b10f9808ed12ab2b1d5a54012865 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch8/0 draft role=translator req=fake-model actual=fake-model hash=c93524c990432bbc36a9866e2a22e9e1ea5b6e6a6bf08b282bde0d79a6bb3f36 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch1/0 edit role=editor req=fake-model actual=fake-model hash=4a75bc83c65ceb170c71a0f52424ebf247db0f8915890488010fa60def476cb3 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch2/0 edit role=editor req=fake-model actual=fake-model hash=5aec367f3096017ab4d46ee5ea776c0f5a6907f8d9c64e054bc54bc8d0485eb4 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch4/0 edit role=editor req=fake-model actual=fake-model hash=a30dd9aebaed1fe56614e4d37e9e32959a6ace1a845a513ead6ca5a6797005c4 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" -ch5/0 edit role=editor req=fake-model actual=fake-model hash=cef18113a5c9f1386418ad27029e541c3ef3bf187032cd6f6debc936bbe40cc0 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch4/0 draft role=translator req=fake-model actual=fake-model hash=c74a4b18be42dae5946fc9d33f1172e6be71a37bad7dc08076e5fba59316e858 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch5/0 draft role=translator req=fake-model actual=fake-model hash=407f023c09e54b0399f79cf59e893817065b28129a807c57b25cc796202d75e1 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch6/0 draft role=translator req=fake-model actual=fake-model hash=afd2169fce4c9aaecdf7f3de1f1f87bc08d5b672d19bc1dc772991f8d3321d60 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch7/0 draft role=translator req=fake-model actual=fake-model hash=486facff54a77c86f44cd322200caf4cf982975eeda552fa6dfdee4aa8df0904 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch8/0 draft role=translator req=fake-model actual=fake-model hash=070d13df73e028346b69abe69253a999f109d92b01f39f3dfada0772be50e185 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch1/0 edit role=editor req=fake-model actual=fake-model hash=799b6e6419910a2e52dd4652f2bde9bee59fbdb92a849f291ed33a5e30410a15 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch2/0 edit role=editor req=fake-model actual=fake-model hash=e3f8acb903b0dd5798636597412acadfcf03a968ff049a8604b1142dea5cded1 tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch4/0 edit role=editor req=fake-model actual=fake-model hash=5f8f5737a4e02766f8500915341c8b8a8827f8a05b03d77a746a4a8bc48806eb tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" +ch5/0 edit role=editor req=fake-model actual=fake-model hash=2b722376f3691a64c38d9a8026f567dfce0dd0a5ca3d2355bd2a95e4807ee67c tm_hit=1 ok=1 finish="stop" degraded="" cost=0 tokens=0/0/0/0/0 err="" ch6/0 edit role=editor req=fake-model actual=fake-model hash= tm_hit=1 ok=0 finish="" degraded="sanitizer_defect" cost=0 tokens=0/0/0/0/0 err="" -ch7/0 edit role=editor req=fake-model actual=fake-model hash=tm-sanitized-v1:93c49c0115fab1b8ac52b94bf513b2c119a7dc44f7fa50b59c8a8a2843a0f373 tm_hit=1 ok=0 finish="sanitized_export" degraded="sanitizer_stripped" cost=0 tokens=0/0/0/0/0 err="" -ch8/0 edit role=editor req=fake-model actual=fake-model hash=tm-sanitized-v1:67de3e29186115d82cbc899002b3e70439f7d85f3794906d3b32bce9ab8b4aea tm_hit=1 ok=0 finish="sanitized_export" degraded="sanitizer_stripped" cost=0 tokens=0/0/0/0/0 err="" +ch7/0 edit role=editor req=fake-model actual=fake-model hash=tm-sanitized-v1:def877b81bcdc52e2525fdcaae04b8c64f632c7d6747b31df7f4c7f1ad176e73 tm_hit=1 ok=0 finish="sanitized_export" degraded="sanitizer_stripped" cost=0 tokens=0/0/0/0/0 err="" +ch8/0 edit role=editor req=fake-model actual=fake-model hash=tm-sanitized-v1:5095da45458381184b8798287c934e133c39668fed86c50cce921a1cb7df7893 tm_hit=1 ok=0 finish="sanitized_export" degraded="sanitizer_stripped" cost=0 tokens=0/0/0/0/0 err="" -- retrieval_state -- ch1/0 snap_match=true exact=3 sticky=0 ambiguous=0 spoiler=1 evicted=0 postcheck_miss=1 style_flags=0 trust_gated=0 injected_ids=["紋章\u001f\u001f0\u001f0","鈴木\u001f\u001f0\u001f0","魔法学院\u001f\u001f0\u001f0"] postcheck_detail=[{"src":"紋章","dst":"герб","disp":"confirmed"}] style_detail= trust_gate_detail= diff --git a/backend/internal/terminology/classify.go b/backend/internal/terminology/classify.go new file mode 100644 index 00000000..e1639633 --- /dev/null +++ b/backend/internal/terminology/classify.go @@ -0,0 +1,76 @@ +package terminology + +import "strings" + +// classify.go: the TYPE re-derivation channel (bank-quality §2, D39.68). The draft type heuristic is wrong +// 12–22% (D39.65 row 36a), and type ∈ {name,place} routes a candidate to transliteration — so a realia +// surface mistyped as a name (元石) is FORCED to «юаньши» instead of being translated. A focused classifier +// pass fixes the type BEFORE the render (the live probe fixed 6/6 branch-harm units; an inline type returned +// WITH the rendering arrives too late to de-bias it). This file is the pure half — the reply parser and the +// honest $0 label screen; the class DEFINITIONS live in the pair's classifier prompt, no pair literal here. + +// Types is the engine's closed type vocabulary. The names are engine identifiers (the miner emits them, the +// glossary stores them, emissionEligible gates on them), so they are Go constants here, not pair data; the +// pair's prompt explains each class in its own language with its own examples. +var Types = map[string]bool{"name": true, "place": true, "title": true, "term": true} + +// ParseTypes turns a classifier reply into key → corrected type, keeping only the terms we asked about and +// only the closed vocabulary. Same tolerant two-field line format and the same accounting discipline as +// ParseReply: an unusable or off-vocabulary line is COUNTED (st.Bad), never silently dropped, so a paid call +// that bought no classification is loud rather than an empty map that reads as "nothing to correct". +func ParseTypes(reply string, expected []string, normalize func(string) string) (map[string]string, ReplyStats) { + want := make(map[string]bool, len(expected)) + for _, k := range expected { + want[k] = true + } + out := map[string]string{} + var st ReplyStats + for _, ln := range strings.Split(reply, "\n") { + if t := strings.TrimSpace(ln); t == "" || strings.HasPrefix(t, "#") { + continue + } + f := splitFields(ln) + if len(f) < 2 { + st.Bad++ + continue + } + key := normalize(f[0]) + if !want[key] { + st.Bad++ + continue + } + if _, dup := out[key]; dup { + continue // first answer wins, as in ParseReply + } + typ := strings.ToLower(strings.TrimSpace(f[1])) + if !Types[typ] { + st.Bad++ + continue + } + out[key] = typ + } + return out, st +} + +// LabelRow is one row the $0 label screen inspects: the corrected type and the consolidated rendering. +type LabelRow struct { + Src string + Type string + Dst string +} + +// TypeLabelMismatches is the HONEST $0 screen (§2, warm-run hygiene). It flags a name/place row whose +// rendering was clearly TRANSLATED (multi-word) — a label/rendering disagreement worth a human's eye. It is +// explicitly NOT a safety net for the transliteration harm: a mistyped name rendered as a single lower-case +// token (元石→юаньши) or a capitalised one (元海→Юаньхай) passes it clean, because the source class the harm +// needs is exactly what a $0 pass cannot recover. The classifier pass is what prevents the harm; this only +// surfaces leftover label noise for review. Deterministic, input order preserved. +func TypeLabelMismatches(rows []LabelRow) []LabelRow { + var out []LabelRow + for _, r := range rows { + if (r.Type == "name" || r.Type == "place") && strings.ContainsRune(strings.TrimSpace(r.Dst), ' ') { + out = append(out, r) + } + } + return out +} diff --git a/backend/internal/terminology/classify_test.go b/backend/internal/terminology/classify_test.go new file mode 100644 index 00000000..88b86a22 --- /dev/null +++ b/backend/internal/terminology/classify_test.go @@ -0,0 +1,59 @@ +package terminology + +import ( + "reflect" + "strings" + "testing" +) + +func TestParseTypesKeepsOnlyAskedAndVocabulary(t *testing.T) { + id := func(s string) string { return s } + reply := strings.Join([]string{ + "元石\tterm", + "方源 name", // spaces instead of a tab — tolerant split + "青茅山 | place", // padded pipe + "家老\tPERSON", // off-vocabulary → refused and counted + "陌生\tterm", // never asked → refused and counted + "元石\tname", // duplicate key → first answer wins + "мусор", // one field → refused and counted + }, "\n") + got, st := ParseTypes(reply, []string{"元石", "方源", "青茅山", "家老"}, id) + want := map[string]string{"元石": "term", "方源": "name", "青茅山": "place"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("parse = %#v, want %#v", got, want) + } + // off-vocabulary PERSON + unasked 陌生 + malformed мусор = 3 bad lines (the duplicate is silently ignored, + // not counted, exactly like ParseReply). + if st.Bad != 3 { + t.Fatalf("unusable/off-vocabulary lines must be COUNTED, got %d", st.Bad) + } +} + +func TestParseTypesNormalizesKey(t *testing.T) { + got, _ := ParseTypes("FANG\tname", []string{"fang"}, strings.ToLower) + if got["fang"] != "name" { + t.Fatalf("the reply key must be normalized by the caller's function, got %#v", got) + } +} + +// TestTypeLabelMismatchesIsHonest pins the screen's DELIBERATE limit: it flags a name/place whose rendering +// was clearly translated (multi-word), but it is NOT a safety net — a mistyped name rendered as one token, +// lower-case (元石→юаньши) or capitalised (元海→Юаньхай), passes it clean. The classifier phase is what +// prevents that harm; asserting the miss keeps a future reader from mistaking this for the guard. +func TestTypeLabelMismatchesIsHonest(t *testing.T) { + rows := []LabelRow{ + {Src: "花家", Type: "name", Dst: "Дом Хуа"}, // translated name → FLAG + {Src: "青茅山", Type: "place", Dst: "гора Цинмао"}, // translated place → FLAG + {Src: "元石", Type: "name", Dst: "юаньши"}, // the harm, single lower-case token → MISSED + {Src: "元海", Type: "name", Dst: "Юаньхай"}, // the harm, capitalised token → MISSED + {Src: "灵泉", Type: "term", Dst: "духовный источник"}, // term is not screened → not flagged + } + got := TypeLabelMismatches(rows) + var srcs []string + for _, r := range got { + srcs = append(srcs, r.Src) + } + if !reflect.DeepEqual(srcs, []string{"花家", "青茅山"}) { + t.Fatalf("only the translated name/place rows must flag, got %v", srcs) + } +} diff --git a/backend/internal/terminology/series.go b/backend/internal/terminology/series.go new file mode 100644 index 00000000..59774934 --- /dev/null +++ b/backend/internal/terminology/series.go @@ -0,0 +1,137 @@ +package terminology + +import "sort" + +// series.go: the SERIES channel (bank-quality §1, D39.68). A grade/rank series — 甲等/乙等/丙等, 一转…九转 — +// shares one generic HEAD and varies only on a modifier. Measured (D39.65 row 21): the drafts disagree on the +// head across batch boundaries (甲等→«класс», 乙丙丁等→«ранг»), because Merge orders candidates by key and the +// shared head sorts by its DIFFERING prefix. The fix (live probe) is co-batching — shown the set at once the +// model picks ONE head; the first-occurrence test (§7.7) showed order/frequency tricks cannot do it, only +// co-batching. This file detects the series; the batcher keeps each one whole in a single call. + +// SeriesParams is the pair-data that governs the channel. It arrives as a value (the package stays pure and +// pair-agnostic): the pipeline resolves it from the source language's declared morphology. +type SeriesParams struct { + // Enabled gates the whole channel. It is true only for DENSE scripts where one rune ≈ one morpheme, so a + // single-rune difference is a real minimal pair; in an alphabetic source care/core differ in one letter by + // coincidence, not by morphology, so the channel is off and Batch behaves exactly as before. + Enabled bool + // HeadFinal says the shared generic word is the TRAILING rune(s) (the CJK modifier-head norm). The differing + // modifier rune is then non-final; with HeadFinal false the head is leading and the modifier is non-initial. + HeadFinal bool + // MinMembers is the smallest set that counts as a series (≤0 → 3). Below three a "series" is just a pair that + // happens to share a character — too weak to override the key order for. + MinMembers int +} + +// DetectSeries returns key → series id (>0) for every candidate that belongs to a head-aware series; a key in +// no series is absent from the map. Empty when the channel is off. +// +// A series is ≥MinMembers surfaces of EQUAL rune length that are identical except in ONE rune position, and +// that position is NOT the head (the last rune for HeadFinal, the first otherwise). So the members share a +// generic head and vary only on the modifier — 甲等/乙等/丙等 (grades of 等). 元石/元海 differ IN the head +// (石/海): different entities, the type step's job (§2), never one series. There is no transitive closure: +// membership is by an exact shared skeleton, so 元气~酒气 (a shared head-rune 气 on two families) never chains +// into a blob the way a naive "differ in one position" rule did (measured: an 11-surface blob mixing three +// families and the protagonist's name). +func DetectSeries(cands []Candidate, p SeriesParams) map[string]int { + min := p.MinMembers + if min <= 0 { + min = 3 + } + if !p.Enabled || len(cands) < min { + return nil + } + // group[skeleton] = the distinct keys that reduce to it by blanking their one modifier position. + type gkey struct { + n, pos int + skel string + } + groups := map[gkey][]string{} + seenInGroup := map[gkey]map[string]bool{} + var order []gkey // first-seen group order, for deterministic assignment + for _, c := range cands { + rs := []rune(c.Key) + n := len(rs) + if n < 2 { // a one-rune surface is all head, no modifier to vary + continue + } + for pos := 0; pos < n; pos++ { + if p.HeadFinal && pos == n-1 { // the last rune is (part of) the head + continue + } + if !p.HeadFinal && pos == 0 { + continue + } + g := gkey{n, pos, blankAt(rs, pos)} + if seenInGroup[g] == nil { + seenInGroup[g] = map[string]bool{} + order = append(order, g) + } + if !seenInGroup[g][c.Key] { + seenInGroup[g][c.Key] = true + groups[g] = append(groups[g], c.Key) + } + } + } + // A key can satisfy several groups (varying at more than one modifier position). Assign biggest-first so + // the strongest series claims its members; a group left below min after its members were claimed elsewhere + // simply does not form. Deterministic: sort by descending size, then by first-seen order. + sort.SliceStable(order, func(i, j int) bool { return len(groups[order[i]]) > len(groups[order[j]]) }) + out := map[string]int{} + next := 1 + for _, g := range order { + var fresh []string + for _, k := range groups[g] { + if out[k] == 0 { + fresh = append(fresh, k) + } + } + if len(fresh) >= min { + for _, k := range fresh { + out[k] = next + } + next++ + } + } + return out +} + +// blankAt returns the key with the rune at pos replaced by a byte no source key contains, so surfaces that +// agree everywhere except pos share a skeleton and nothing else collides onto it. +func blankAt(rs []rune, pos int) string { + out := make([]rune, len(rs)) + copy(out, rs) + out[pos] = 0 + return string(out) +} + +// orderBySeries returns the candidates with every series' members made contiguous, anchored at the position +// of the series' first member in the input order; non-series candidates keep their place. The input is +// already key-sorted, so the result is deterministic and disturbs the key order minimally. +func orderBySeries(cands []Candidate, seriesID map[string]int) []Candidate { + if len(seriesID) == 0 { + return cands + } + byID := map[int][]Candidate{} + for _, c := range cands { + if id := seriesID[c.Key]; id != 0 { + byID[id] = append(byID[id], c) + } + } + out := make([]Candidate, 0, len(cands)) + emitted := map[int]bool{} + for _, c := range cands { + id := seriesID[c.Key] + if id == 0 { + out = append(out, c) + continue + } + if emitted[id] { + continue // a later member of an already-emitted series + } + out = append(out, byID[id]...) + emitted[id] = true + } + return out +} diff --git a/backend/internal/terminology/series_test.go b/backend/internal/terminology/series_test.go new file mode 100644 index 00000000..09f4932e --- /dev/null +++ b/backend/internal/terminology/series_test.go @@ -0,0 +1,136 @@ +package terminology + +import ( + "strings" + "testing" +) + +var zhSeries = SeriesParams{Enabled: true, HeadFinal: true} + +func cand(key string, freq int) Candidate { return Candidate{Key: key, Src: key, Type: "term", Freq: freq} } + +// TestDetectSeriesHeadAware pins the head-aware rule against the exact patterns the naive "differ in one +// position" rule broke on (measured on BANK-FULL): a grade set clusters, but a set differing IN the head +// does not (that is the type step's job), a shared head-rune across two families never forms a blob, and a +// one-rune surface is never a member. +func TestDetectSeriesHeadAware(t *testing.T) { + cands := []Candidate{ + cand("甲等", 3), cand("乙等", 3), cand("丙等", 3), cand("丁等", 3), // grades of 等 → ONE series + cand("元石", 40), cand("元海", 40), cand("元火", 5), // differ IN the head 石/海/火 → NOT a series (§2's job) + cand("元气", 6), cand("酒气", 6), // share head 气 but only two → below min, no blob + cand("转", 9), // one rune → never a member + } + got := DetectSeries(cands, zhSeries) + + grade := []string{"甲等", "乙等", "丙等", "丁等"} + id := got[grade[0]] + if id == 0 { + t.Fatalf("the grade set must form a series: %v", got) + } + for _, k := range grade { + if got[k] != id { + t.Fatalf("%s must join the grade series (id %d), got %d", k, id, got[k]) + } + } + // The §1↔§2 reconciliation: 元石/元海 differ in the HEAD, so they are different entities, not a series. + for _, k := range []string{"元石", "元海", "元火", "元气", "酒气", "转"} { + if got[k] != 0 { + t.Fatalf("%s must NOT be a series member (id %d) — the naive rule's failure mode", k, got[k]) + } + } +} + +// TestDetectSeriesLongerHead covers a multi-rune shared head (甲等资质/乙等资质/丙等资质): the differing rune +// is the leading modifier, the whole 等资质 tail is the head. +func TestDetectSeriesLongerHead(t *testing.T) { + cands := []Candidate{cand("甲等资质", 2), cand("乙等资质", 2), cand("丙等资质", 2)} + got := DetectSeries(cands, zhSeries) + if got["甲等资质"] == 0 || got["甲等资质"] != got["乙等资质"] || got["乙等资质"] != got["丙等资质"] { + t.Fatalf("a set sharing the trailing head 等资质 must form one series: %v", got) + } +} + +// TestDetectSeriesRespectsPairData: the channel is off for a pair whose source is not dense-script — the same +// single-position difference that clusters for zh must NOT cluster when Enabled is false (care/core is a +// spelling coincidence), and the head side flips with HeadFinal. +func TestDetectSeriesRespectsPairData(t *testing.T) { + alpha := []Candidate{cand("care", 3), cand("core", 3), cand("cure", 3)} + if got := DetectSeries(alpha, SeriesParams{Enabled: false, HeadFinal: true}); len(got) != 0 { + t.Fatalf("series channel must be inert for a non-series pair, got %v", got) + } + // Head-INITIAL direction: the head is the leading rune, the modifier is non-initial. 등X where the tail + // varies and 등 is shared as the head. + headInit := []Candidate{cand("등가", 1), cand("등나", 1), cand("등다", 1)} + got := DetectSeries(headInit, SeriesParams{Enabled: true, HeadFinal: false}) + if got["등가"] == 0 || got["등가"] != got["등나"] || got["등나"] != got["등다"] { + t.Fatalf("with HeadFinal=false the leading rune is the head and the trailing modifier varies: %v", got) + } + // The same set under HeadFinal=true shares no head (they differ at the last position) → no series. + if got := DetectSeries(headInit, zhSeries); len(got) != 0 { + t.Fatalf("under head-final the trailing-varying set is not a series, got %v", got) + } +} + +// TestBatchKeepsSeriesWhole: a series scattered across key order lands in ONE batch even under a budget so +// tight every singleton is its own batch — co-batching is the whole mechanism. +func TestBatchKeepsSeriesWhole(t *testing.T) { + // Key-sorted order interleaves the series with a non-member; a tiny budget would otherwise split it. + cands := []Candidate{cand("丁等", 3), cand("丙等", 3), cand("乙等", 3), cand("甲等", 3), cand("中间", 50)} + seriesID := DetectSeries(cands, zhSeries) + batches := Batch(cands, 10, seriesID) // 10 runes: every unit overflows, so packing cannot help by luck + var seriesBatch []Candidate + for _, b := range batches { + for _, c := range b { + if seriesID[c.Key] != 0 { + seriesBatch = b + } + } + } + if len(seriesBatch) != 4 { + t.Fatalf("all four grade members must share one batch, got %d: %v", len(seriesBatch), batches) + } + // Every candidate still appears exactly once across all batches. + seen := map[string]int{} + for _, b := range batches { + for _, c := range b { + seen[c.Key]++ + } + } + if len(seen) != len(cands) { + t.Fatalf("batching dropped or duplicated a candidate: %v", seen) + } +} + +// TestBatchValueOrder: batches are processed most-frequent first, so a budget ceiling drops the rarest terms +// (feed_cap) rather than the lexicographic tail. Content of each batch is untouched by the ordering. +func TestBatchValueOrder(t *testing.T) { + // Three singletons, each its own batch under a tiny budget; frequency is the only thing that differs. + cands := []Candidate{cand("阿", 1), cand("布", 99), cand("此", 50)} + batches := Batch(cands, 5, nil) + if len(batches) != 3 { + t.Fatalf("expected three singleton batches, got %d", len(batches)) + } + if batches[0][0].Key != "布" || batches[len(batches)-1][0].Key != "阿" { + t.Fatalf("batches must run most-frequent first, least-frequent last: %v", batches) + } +} + +// TestBatchNilSeriesMatchesLegacyOrder guards the alphabetic/off path: with no series and uniform frequency, +// Batch preserves the incoming key order exactly (the pre-§1 behaviour), so a book that forms no series takes +// a byte-identical path. +func TestBatchNilSeriesMatchesLegacyOrder(t *testing.T) { + var cands []Candidate + for _, k := range []string{"a", "b", "c", "d", "e"} { + cands = append(cands, Candidate{Key: k, Src: k, KWIC: []string{strings.Repeat("к", 200)}}) + } + batches := Batch(cands, 400, nil) + var order []string + for _, b := range batches { + for _, c := range b { + order = append(order, c.Key) + } + } + if strings.Join(order, "") != "abcde" { + t.Fatalf("uniform-frequency, series-free batching must preserve key order, got %v", order) + } +} diff --git a/backend/internal/terminology/terminology.go b/backend/internal/terminology/terminology.go index 9546dd30..58aadf8e 100644 --- a/backend/internal/terminology/terminology.go +++ b/backend/internal/terminology/terminology.go @@ -914,31 +914,66 @@ func ParseReply(reply string, expected []string, normalize func(string) string, return out, st } -// Batch splits candidates into groups whose rendered size stays under maxRunes, preserving key order. A -// single candidate larger than the budget still gets its own batch (never silently dropped). -func Batch(cands []Candidate, maxRunes int) [][]Candidate { - if maxRunes <= 0 || len(cands) == 0 { - if len(cands) == 0 { - return nil - } +// Batch splits candidates into groups whose rendered size stays under maxRunes. A series (a key present in +// seriesID; pass nil to disable the channel) is kept WHOLE in one batch so the terminologist picks one +// generic head for it (§1); a series or a single candidate larger than the budget still gets its own batch +// rather than being split or silently dropped. The batches are then ordered by descending source frequency, +// so a budget ceiling drops the least-frequent terms instead of the lexicographic tail (feed_cap, §7). Every +// batch's CONTENT — hence its request hash and $0 resume — is independent of this ordering; only the call +// order moves. Deterministic throughout. +func Batch(cands []Candidate, maxRunes int, seriesID map[string]int) [][]Candidate { + if len(cands) == 0 { + return nil + } + if maxRunes <= 0 { return [][]Candidate{cands} } + ordered := orderBySeries(cands, seriesID) var out [][]Candidate var cur []Candidate size := 0 - for _, c := range cands { - // +2 for the blank line RenderBatch puts BETWEEN blocks: without it the accountant counts something - // the renderer does not emit, and a 200-term book overruns the configured budget by 2×(N−1) runes. - n := len([]rune(RenderBatch([]Candidate{c}))) + 2 - if len(cur) > 0 && size+n > maxRunes { + flush := func() { + if len(cur) > 0 { out = append(out, cur) cur, size = nil, 0 } - cur = append(cur, c) - size += n } - if len(cur) > 0 { - out = append(out, cur) + for i := 0; i < len(ordered); { + j := i + 1 // extend over a whole series block; a singleton is a unit of one + if id := seriesID[ordered[i].Key]; id != 0 { + for j < len(ordered) && seriesID[ordered[j].Key] == id { + j++ + } + } + unit := ordered[i:j] + if n := unitRunes(unit); len(cur) > 0 && size+n > maxRunes { + flush() + cur, size = append(cur, unit...), n + } else { + cur, size = append(cur, unit...), size+n + } + i = j } + flush() + sort.SliceStable(out, func(a, b int) bool { return batchFreq(out[a]) > batchFreq(out[b]) }) return out } + +// unitRunes is the rendered size the accountant charges a unit: each member's single-block render plus the +// blank line RenderBatch puts between blocks (+2). Without the +2 a 200-term book overruns its budget by +// 2×(N−1) runes. Summing per member matches the original per-candidate arithmetic exactly. +func unitRunes(unit []Candidate) int { + n := 0 + for _, c := range unit { + n += len([]rune(RenderBatch([]Candidate{c}))) + 2 + } + return n +} + +func batchFreq(b []Candidate) int { + n := 0 + for _, c := range b { + n += c.Freq + } + return n +} diff --git a/backend/internal/terminology/terminology_test.go b/backend/internal/terminology/terminology_test.go index c9932a3a..8f20fcc0 100644 --- a/backend/internal/terminology/terminology_test.go +++ b/backend/internal/terminology/terminology_test.go @@ -354,7 +354,7 @@ func TestParseReplyKeepsAWholeRenderingAndBatchBudgetHolds(t *testing.T) { for i := range cands { cands[i] = Candidate{Key: "k", Src: "s", Type: "term"} } - for i, b := range Batch(cands, 600) { + for i, b := range Batch(cands, 600, nil) { if n := len([]rune(RenderBatch(b))); n > 600 { t.Fatalf("batch %d renders %d runes over a 600-rune budget", i, n) } @@ -409,7 +409,7 @@ func TestBatchCoversEveryCandidateExactlyOnce(t *testing.T) { for _, k := range []string{"a", "b", "c", "d", "e"} { cands = append(cands, Candidate{Key: k, Src: k, KWIC: []string{strings.Repeat("к", 200)}}) } - batches := Batch(cands, 400) + batches := Batch(cands, 400, nil) if len(batches) < 2 { t.Fatalf("the fixture must actually split, got %d batch(es)", len(batches)) } @@ -429,7 +429,7 @@ func TestBatchCoversEveryCandidateExactlyOnce(t *testing.T) { } // A single oversized candidate is never silently dropped. big := []Candidate{{Key: "big", Src: "big", KWIC: []string{strings.Repeat("ю", 5000)}}} - if got := Batch(big, 10); len(got) != 1 || len(got[0]) != 1 { + if got := Batch(big, 10, nil); len(got) != 1 || len(got[0]) != 1 { t.Fatalf("an oversized candidate must still get its own batch, got %v", got) } } diff --git a/backend/prompts/zh-ru/classifier.md b/backend/prompts/zh-ru/classifier.md new file mode 100644 index 00000000..f0a61483 --- /dev/null +++ b/backend/prompts/zh-ru/classifier.md @@ -0,0 +1,43 @@ + + +Ты — классификатор терминов издательского перевода с языка «{{source_lang}}» на «{{target_lang}}». +Книга: «{{title}}». Жанр: {{genre}}. Аудитория: {{audience}}. + +Твоя задача — НЕ переводить. По каждому термину книги реши, к какому из ЧЕТЫРЁХ классов он относится. +Класс определяет, как термин будет переведён дальше: имена и топонимы ТРАНСКРИБИРУЮТСЯ, а титулы и +реалии переводятся ПО СМЫСЛУ. Ошибка класса — это реалия, ошибочно записанная как имя, которую потом +транслитерируют в бессмысленный слог (например 元石, «первородный камень», стал бы «юаньши»). Поэтому +решай по КОНТЕКСТУ, а не по строке `type:` — она черновая и может быть неверна. + +Классы (ответ давай ровно этим английским словом): +- `name` — имя собственное персонажа или существа: 方源 (Фан Юань), 青鸟 как кличка. Транскрибируется. +- `place` — топоним: гора, город, секта как место, дворец. 青茅山 (гора Цинмао). Транскрибируется. +- `title` — титул, звание, ранг, ДОЛЖНОСТЬ, а также название произведения внутри книги (стихи, песни, + трактаты, приёмы-техники как ИМЕНОВАННЫЕ произведения): 家老 (старейшина), 甲等 (ранг). Переводится по смыслу. +- `term` — реалия, понятие, предмет, материал, вещество, класс существ — всё, что переводится ПО СМЫСЛУ, + а не транслитерацией: 元石 (первородный камень), 蛊 (гу — класс существ), 灵泉 (духовный источник), + 窍 (апертура). Если сомневаешься между `name`/`place` и `term`, а термин обозначает ВЕЩЬ или ПОНЯТИЕ, + а не конкретное лицо или место, выбирай `term`. + +Как решать: +- Опирайся на строки `ctx:` — они показывают, чем термин является в тексте. Одна и та же морфема бывает + и частью имени, и реалией; контекст различает их. +- Строка `type:` — черновая догадка, которую ты и проверяешь. Не повторяй её механически. +- Не выдумывай новых классов и не отвечай ничем, кроме одного из четырёх слов выше. + +Формат ответа — по одной строке на термин, ровно два поля, разделённые СИМВОЛОМ ТАБУЛЯЦИИ: +первое поле — термин исходника, второе — класс (name | place | title | term). + +Пример строки ответа (символ между полями — настоящая табуляция): + +元石 term + +Никаких заголовков, нумерации, комментариев и markdown. Термины, которых нет в списке, не добавляй. + +---USER--- +Термины книги: + +{{text}} diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index dc4c60be..8c007584 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,9 +1,9 @@ # Журнал прогресса -> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-01, голова D39.68). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1–D39.68); этот файл — ЖУРНАЛ.** +> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-02, голова D39.69). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1–D39.69); этот файл — ЖУРНАЛ.** > - **Сделано (сводно; детали — D-лог и архив-слайсы):** Ф0 ✅ · Ф1-инфра ✅ · арх-ресет D39 (7 слоёв, паки 11–16) ✅ · паки 17 «канал B» · 18 «долги» · 19 «голос/состояние» · 20 «банк+терминолог» ✅ (D39.26–28/31/41–56) · мини-прогон (D39.37) и ХОЛОДНЫЙ прогон (D39.58: recall банка 0.918/0.980, банк приходит переведённым) приняты · полигон-пакеты 5–8, ToS-речек (D39.57), Р6+P4 (D39.61) закрыты · **карта языковой привязки движка (D39.60): 149 сайтов/45 файлов, книго-ось чиста (4), ja→ru безопасно без правки Go, en→ru — нет** · Р1–Р4 закрыты целиком (D39.63: Р2 = ЗНАЧЕНИЕ, Р4 = норма потолка recall) · **ФАЗА 2 ОБЩНОСТИ ✅ (D39.64: П0 эмбед-хеш · П1 цель-шов · П2 скрипт-шов, ko-баг закрыт · П3 нарезка · П4 манифест-по-каналам; голден вердикт-нейтрален 0/172, майнер-парити EXACT)** · жанровый словарь отменён как класс (D39.47) · петля ремонта построена и НЕ включена (D39.38). -> - **Курс (D39.59–67): ОБЩНОСТЬ ✅ → КАЧЕСТВО БАНКА (сейчас; термин владельца D39.67, бывш. «автономность»: банк обязан приходить ВЕРНЫМ без ручной правки, подпись опциональна).** Обе ветки параллели ЗАКРЫТЫ: фаза 2 общности (D39.64; 9 флагов §5 — строка 14) и полигон-сырьё+пробы банка (D39.65). **Текущее: ДВА бэкенд-промта ВЫДАНЫ (D39.68, хендофф владельца — дизайн у бэкендеров): банк-качество (этап 1 ресёрч+дизайн, read-only) ∥ пакет-чекеров (стройка) — обе кучки $0, платные прогоны стоят на развилке 74. Оба промта несут глобальный критерий + мандат ревизии посылок.** ⚠ **DeepSeek-V4-Flash-0731: веса сменились под слагом — боевая черновая форма покупает пустые ответы (бьёт и терминолога); платные прогоны СТОП до ре-пробы (строка 74; оперативно «(в) ждём», D39.63). Перекупок на этой фазе нет — стендовый корпус одноразовый (D39.63).** -> - **Горизонт (D39.62 «планировать подальше», хирургия D39.67):** дизайн-пак КАЧЕСТВА банка (серия-батчинг · тип-пере-вывод · Decl-шов/27 · 36б · веб-фетч пере-замер · вопрос резюме-слоя 80) → СТРОЙКА банка ∥ пакет-чекеров (25, $0) → ре-проба flash (74) → **ДОБОР ИДЕАЛА** (первым прогоном влиты бывш. оси мини-прогона: голос 24 · авто-режим · итерация №2 редакторов 65 · полная цена 16; плюс свип гипотез качества: веса K1–K12 13а · стенд пакета-6 · вне-претрейн чекпоинт 55) → ВТОРАЯ ПАРА живьём (ja→ru; гейтит П5-майнер и §0 фактом; преп 81) → МАСШТАБ (7,78 млн симв., ~2284 раздела) → пилот Ф2.5 (судья; строки 62–68, 85) → Ф3 ридер-IDE (строки 69–71). **Стоячие:** ToS-триггер 25.10 · Ш-2 трипваер до go1.27 · строка 74 перед любым платным прогоном. +> - **Курс (D39.59–69): ОБЩНОСТЬ ✅ → КАЧЕСТВО БАНКА ✅ (ядро построено).** Банк-пак принят целиком (D39.69: серия-батчинг head-aware · тип-классификатор первичным · Decl-стеммер+neuter; 36а/36в/27/84/feed_cap закрыты; голден вердикт-нейтрален, «4 серии на живом BANK-FULL» воспроизведено независимо). **Текущее: ПАКЕТ-ЧЕКЕРОВ (промт `BACKEND_CHECKER_PACKAGE_SESSION_PROMPT.md` выдан, сессия НЕ запущена) + фикс-лист банка (строка 87, несущее — «ь» в decl_suffix) + 8 вопросов владельцу (строка 88, вкл. ⚠Q8 книжные каноны в classifier.md).** ⚠ **DeepSeek-V4-Flash-0731: платные прогоны СТОП до ре-пробы (строка 74; при ре-пробе добрать платный порог §2 «6/6 harm»). Перекупок нет — стендовый корпус одноразовый (D39.63).** +> - **Горизонт (D39.62/67):** пакет-чекеров (строка 25) + фикс-лист банка (87) → ре-проба flash (74; + платный порог 6/6 классификатора + пробы 36б-замера Q2/эмиссии Q7 по слову) → **ДОБОР ИДЕАЛА** (первым прогоном: оси голоса 24 · авто-режим · итерация №2 редакторов 65 · цена 16 · веса K1–K12 13а · вне-претрейн чекпоинт 55) → ВТОРАЯ ПАРА живьём (ja→ru; преп 81) → МАСШТАБ → пилот Ф2.5 (гейт резюме-строки 80; строки 62–68, 85) → Ф3 ридер-IDE (69–71). **Стоячие:** ToS-триггер 25.10 · Ш-2 до go1.27 · строка 74 перед любым платным прогоном. > - **Стек:** draft deepseek-v4-flash thinking-ON (+банкнота) **⚠0731** → терминолог (та же модель, батчи, экран `target_script`) → editor deepseek-v4-pro БИЛИНГВ ИНТЕРИМ (вендором НЕ тронут; glm-5 резерв) → судья gemini (Ф2, полигон); канал B Mistral+grok; ~$0.85/ранобэ (D30.4) — пере-калибровка после развилки 74. > - **ЕДИНЫЙ БЭКЛОГ — секция «Бэклог» ниже** (одна таблица, единственный трекер; каждая петля обязана иметь диспозицию: решено / отложено-с-записью / отклонено; ведёт оркестратор). > - Архивы хроники: `archive/PROGRESS-2026-07-04-10.md` (D31) · `-10-13` (D39.6-гигиена) · `-13-25` (стройка паков 11–16, rerun2) · **`-25-31` (паки 17–20 · мини-прогон · полигон-пакеты 5–8 · ToS · холодный прогон; D39.26–58)**. Записи ниже — живой хвост (эра D39.59+). @@ -17,7 +17,7 @@ | **— ВИСИТ НА ВЛАДЕЛЬЦЕ —** | | | | | | | 2 | Четыре вопроса границы «алгоритмического идеала» (Annotator · семантический судья · ja→ru §B5 · ru-target до/после) | владелец | скоро (гейт «добора идеала» перед МАСШТАБОМ) | отдельное решение | D39.33, D39.38 | | 4 | Ре-чек ToS по триггерам — первый ИСПОЛНЕН 31.07, дельт вердиктов нет (D39.57); предмет Google-мониторинга перевешен на цепочку договора (PUP · Additional ToS · Google APIs ToS); следующие: квартал 25.10.2026 · первая лейблованная книга · любая правка `accepts_labels` | владелец/полигон | скоро (следующий триггер 25.10.2026) | отдельное решение (правило) | D39.32, D39.57 | -| 5 | Судья-с-декоем — условие «после холодного старта» НАСТУПИЛО (D39.58); форма возврата = рецензент второго мнения по банку, несогласия колонкой подписной таблицы (строка 36б) | владелец | скоро | дизайн 36б | D39.50 п.6, D39.51 п.4, D39.58 | +| 5 | Судья-с-декоем/36б «второе мнение» — бэкенд-стройка ЗАКРЫТА дизайном (D39.69): маршрут = полигон-ЗАМЕР нужности рецензента-другого-семейства ПОВЕРХ §1+§2 (Q2); переживёт замер → тонкая колонка наблюдаемости, не гейт | владелец → полигон | когда-нибудь (по слову, можно при ре-пробе 74) | полигон-замер → решение | D39.50 п.6, D39.58, D39.69 | | 6 | Строка Gemini в `accepts_labels` — ждёт слова владельца о подписи интерпретации сужения как разрешения; ре-чек 31.07 понизил приоритет: подпись даёт ПРАВО, но не судью 18+ (фильтр неконфигурируем D22.6 + обход запрещён договором) — возвращаться при вопросе, требующем именно права | владелец | когда-нибудь | отдельное решение | D39.32, D39.57 | | 7 | Q2 «редактор под `sexually-explicit`» (grok-4.3 интерим · glm-5 после сверки ToS Z.AI · mistral вон) | владелец | когда-нибудь (перед первой explicit-книгой) | отдельное решение | D39.27 п.7 | | 8 | D22.7 пер-чанковый L3-скрин — несня­тое предусловие первой erotica-книги в проде, отдельный гейт | владелец | когда-нибудь | отдельное решение | D22.7, CURRENT-STATE (оркестратор) | @@ -29,26 +29,25 @@ | 13а | Веса рубрики K1–K12 не выбраны (Q1: «по данным фазы B»; фаза B прошла на BWS без весов — выбор нужен к агрегатной приёмке качества книги) | владелец | когда-нибудь (к пилоту) | Ф2.5 / отдельное решение | D39.44 Q1, D39.46 | | 13б | `speech-cue.txt` zh НЕ шипить до замера голос-флаггера на холодном прогоне (данные для недоказанного детектора = инерция; вывод обсуждения 31.07): оси пустые → файл не нужен, оси ловят → решение владельца о шипе (байты двигают `LangpackVersion`) | владелец | отложено-до-замера | первый прогон добора идеала (оси голоса, строка 24) → отдельное решение | D39.56, PACK19_BUILD §7.2, чат 31.07 | | 14 | Четыре data-инженерных флага фазы-2 общности, каждый с рекомендованным дизайном (§5 отчёта): ё-фолд → target-данные (`char_fold`; смена сигнатуры core-примитива через вердикт-несущий банк, сдвиг `memory_version`) · segmentation-фолбэк громким (ломает контракт corridor-фолбэка `pipeline.go:839-848` + голден-фикстура на нём; лечение — объявить блок в голден-yaml) · `Fertility` per-script map (калибровка ждёт боевого токенизатора) · `source-encoding.txt` + Go-реестр декодеров | владелец | когда-нибудь (следующий пак общности) | отдельное решение | D39.64, GENERALITY_PHASE2 §5.1–5.4 | -| 86 | Мелкие открытые решения одним списком (свип D39.66): echo-семантика дропнутого c-lite-члена (D39.18 — оставить pre-c-lite?) · magnitude-кап тяжёлой CJK-утечки (D38.3 п.4в; сейчас strip+flag) · feed_cap терминолога (D39.45; предметно пере-смотрит дизайн-пак 73) | владелец | когда-нибудь | отдельные решения / дизайн-пак 73 | D39.66 | +| 86 | Мелкие открытые решения одним списком (свип D39.66): echo-семантика дропнутого c-lite-члена (D39.18 — оставить pre-c-lite?) · magnitude-кап тяжёлой CJK-утечки (D38.3 п.4в; сейчас strip+flag) | владелец | когда-нибудь | отдельные решения | D39.66, D39.69(feed_cap закрыт §1) | | **— ХВОСТЫ ХОЛОДНОГО ПРОГОНА (исполнен достаточно, D39.58; Р7 владельца) —** | | | | | | | 16 | Полная цена холодного старта: edit-волна не гонялась (Р7); измерено всё ДО редактуры (драфт+банкнота $0.0247/волна · терминолог $0.006–0.013/проход) | бэкенд | когда-нибудь (первый прогон добора идеала) | добор идеала | D39.58 | -| 18 | `蛊` не приходит НИ ОДНИМ каналом (модель объявляет составные, майнер режет по длине) — структурная дыра эмиссии на голове частотного понятия; доставка подписанной короткой строки проверена $0-пином; третий сайт того же порога — алиас-канал `miner_alias.go:44,93` (D39.60) | бэкенд | когда-нибудь | отдельное решение (рядом с 36а) | D39.58, D39.60 | +| 18 | Эмиссия головы 蛊: сид-половина = чек-лист (определяющий терм книги сидится `allow_short`, D39.50); эмиссия-КАНАЛ демотирован до ИЗМЕР-ГЕЙТА (Q7): полигон-замер «меняет ли эмиссия головы рендер композитов» — строить ТОЛЬКО по эффекту и ПОСЛЕ #10 (однознаковый пост-чек чинится чекер-паком); перед стройкой резолвить K-гейт и KWIC-грязь одиночной головы | полигон → бэкенд | когда-нибудь (после #10, по слову Q7) | полигон-замер → решение | D39.58, D39.60, D39.69 | | 24 | Остаток: авто-режим флага (проект Б не гонялся) · оси голоса A–D (профили не подписаны; черновик готов — `coldrun-a/SIGN-PACKAGE.md` §3) ; + слот инъекции голоса и решение gates.voice при ненулевых осях (D39.55/D21 п.2) · слой-2 voice не строим до доказательства слоя-1 · сверить until_ch 白凝冰 при курации сида (D19.3в) | бэкенд | когда-нибудь (первый прогон добора идеала) | добор идеала | D39.40, D39.58 | | **— ТЕКУЩАЯ ОЧЕРЕДЬ (D39.59) —** | | | | | | -| 73 | Арка КАЧЕСТВА банка (термин D39.67) — ВХОД СОБРАН (D39.65: 21/22 закрыты, 36а/36в с проверенными фиксами, Decl 142/142 null, веса §C2-3 не тюнить — рычаг в контекстах/серии): дизайн-пак оркестратора = серия-батчинг по морфеме · тип-пере-вывод · Decl-шов (терминолог эмитит формы / морфопроход) · 36б второе мнение · Р3-остаток (сокращённая форма, строка 27) · пере-замер веб-фетча под АВТОНОМНЫМ критерием; заметка: first-occurrence-тест KWIC-консолидатора — кандидат $0-захода ; + кандидаты свипа D39.66: ranked-set: тип записи + порядок ступеней (инъективность dst УЖЕ есть — `InjectivityCollisions` warning-only; решить hard-режим для ranked-set) · STITCH-ретирование инъекции · tmctl «замена термина с главы N» · эмбеддинг-второй-эшелон A7/D2 (дать диспозицию) · feed_cap (D39.45) · вопрос резюме-слоя (строка 80) | бэкенд | в работе (промт выдан D39.68: этап 1 дизайн → СТОП → ратификация → этап 2 стройка) | `BACKEND_BANK_QUALITY_SESSION_PROMPT.md` | D39.59, D39.60, D39.65 | | 74 | ⚠ **DeepSeek-V4-Flash-0731: боевая черновая форма покупает пустые ответы — блокер ПЛАТНЫХ прогонов.** Оперативное состояние = «(в) ждать» (D39.63; фаза-2 всё равно $0-код). Перед следующим платным прогоном — ре-проба flash за копейки → если не устаканилось, выбор: (а) флор 16000 (сдвиг request_hash безболезнен — перекупок на этой фазе нет; хвост не закрывает, упор 2/5) или (б) слать `reasoning_effort` явно (вендорская модель, дешевле ~22%; но амендмент `echoMineViolation`, эхо ~6% и качество черновика при `low` не судилось — нужен замер ~$0.05; тогда же предмет для постинструкции/префилла); включение ручки #77 (ре-ген эха перед эскалацией, построена D39.64, дефолт 0) — тем же решением; при включении помнить общий счётчик attempt с length-ре-геном + рост `maxTokensForAttempt`; остаток §12.7 (8 несудимых fidelity-пар ≈$0.10 · ja-вход ≈$0.005 · prefill-continuation research/21 §1.17) — вернуться при предмете wiring; ⚠ cap 8000 бьёт и СТАДИЮ ТЕРМИНОЛОГА (reasoning 8063/батч, D39.65) — резолюция обязана накрыть терминолога | владелец (по ре-пробе) | перед следующим платным прогоном | ре-проба → решение | D39.61, D39.63, D39.64, POLYGON_PROMPTLANG_ANTIECHO §12.4 | | 78 | Леджер денег = НИЖНЯЯ граница: «2xx body decode failed (call IS billed)» — провайдер списал, исходная попытка в `request_log` НЕ попадает (≤$0.009/сессия); дизайн фикса ГОТОВ (GENERALITY_PHASE2 §5.6: `BilledDecodeFails` + per-billed-attempt settle с `Estimated=true`), но это money/ledger-путь — не $0-хастл | бэкенд | когда-нибудь (money-паком) | отдельное решение (дизайн §5.6) | D39.61, D39.64 | | 79 | Остаточные Cyrillic-хардкоды пар-гейтед DC-чекеров: `checks/repair.go:131` (трим DC1-спана) · `checks/checkers.go:457,474` (`isCyrLetter` словограница register) — инертны без dc-данных пары, но пара С DC-чекерами и не-ru целью потребует правки Go; кандидат на `c.wordScript` ; + lintNumberMagnitude гейтить по объявленному письму источника (находка §8 карты D39.60) | бэкенд | когда-нибудь (следующий пак цель-оси) | ближайшее касание чекеров | D39.64 | +| 87 | Фикс-лист банк-пака (D39.69, приёмка): **«ь» (и класс -нь/-ль) в `decl_suffix` — приёмочный кейс «Фан Юаню»→«Фан Юань» сейчас ПРОВАЛИВАЕТСЯ (одна строка данных + пере-захват голдена)** · комментарий `Batch` «request hash independent of ordering» неверен (ChunkIdx фолдится — поправить) · оверсайз-серия одним батчем (помнить мину cap-8000 терминолога) · classify_types при выключенном terminology-гейте = тихий no-op + config-тесты classify-веток · мутационный тест neuter-фолда · voice-checker без стеммера (решить: заводить или объявить не-нужным) · дистилл-фикстура BANK-FULL (пин «4 серии») | бэкенд | скоро (малой пачкой при следующем касании банка; можно паровозом к чекер-паку по слову владельца) | ближайшее касание | D39.69 | +| 88 | Вопросы дизайн-пака банка §9 у владельца: Q1 канон-пин серий между прогонами (реком: не сейчас) · Q2 полигон-замер 36б (реком: да, до стройки) · Q3 подтвердить резюме-гейт Ф2.5 · Q4 hard-инъективность ranked-set (реком: нет) · Q5 вкус авто-транслита реалий (юаньци/цзин-ци-шэнь — промпт-инструкция?) · Q6 «сокращённая форма» = морфоклип или прозвище + компат neuter-сидов · Q7 замер эмиссии головы 蛊 · **⚠Q8 книжные каноны в `classifier.md` (方源 и др.) — санкция или жанрово-генерические примеры** | владелец | скоро | ответы → мелкие правки | D39.69, BANK_QUALITY_DESIGN §9 | | **— НАХОДКИ СВИПА ПОЛНОТЫ (D39.66: 951 обязательство проверено, потери возвращены в трекер) —** | | | | | | -| 80 | Резюме-слой памяти (глава→арка→книга) + fact-gate (R9/Q6, D25 п.8–9; «вторая половина банка», реестр-06 D1 «первоклассный пробел») — дизайн-пак 73 ОБЯЗАН дать диспозицию: строить в Ф2.5 или закрыть с обоснованием «KWIC-банк+reveal-окна достаточны» | бэкенд/владелец | скоро (вопросом в дизайн-пак 73) | дизайн-пак 73 → отдельное решение | D25 п.8–9, D30.8, 06-реестр D1, D39.66 | +| 80 | Резюме-слой памяти — проза-суммарайзер ЗАКРЫТ (D39.69); строка = ГЕЙТ ПИЛОТА Ф2.5: первый деливерабл пилота — «допускает ли автономная нарратив-состояние-строка ДЕТЕРМИНИРОВАННЫЙ верификатор (source-anchored) — или это D1-компаундинг со схемой»; не-покрытые классы (source-anchored reveal · арк-колбэки без ключа) реальны, но не измерены как дефект | владелец (Q3) → Ф2.5 | когда-нибудь (пилот) | Ф2.5 пре-рег | D25 п.8–9, D39.66, D39.69 | | 81 | ja-преп B6-ja (сужено пост-сверкой D39.66): Поливанов-валидатор (`translit_policy` = «Phase 2»-заглушка, migrate.go:186/194) + kana-омограф POS/known-word гейтинг / B6-токенизатор (memory.go:1048); чек-лист kana-алиасов сида УЖЕ построен (backend/README §ja + `AttachRubyAliasesToManual`) — остаток в нём только дизамбигуация двойных чтений (тот же B6) | бэкенд | когда-нибудь (перед ja→ru) | ja→ru-пак (рядом 35) | D16.4, D17.1, D18, D39.66 | | 82 | Морфо-гейт РОДА (C3: русский глагол прош. вр. при gender=hidden = механический спойлер-канал; «жалоба №1 читателей MTL»; python-сайдкар/pymorphy, связка с морфопроходом Decl из 73) | бэкенд | когда-нибудь (Ф2-гейты) | отдельный пак (с 52) | 06-реестр C3, research/12, D39.66 | | 83 | F4: бэкап + integrity_check SQLite-файла книги перед платным прогоном (VACUUM INTO; SPOF — тихая потеря банка и подписей владельца) | бэкенд | когда-нибудь (до МАСШТАБА) | малый пак (с 67/78) | 06-реестр F4, D39.66 | -| 84 | Ратифицированный канон D39.21 «generic „гу“ = СРЕДНИЙ род» без носителя реализации: gender-enum neuter (сдвиг memory_version) + сид-дельта, либо явное закрытие «decl.invariant достаточно» ; пост-сверка: `gender: neuter` в сиде сегодня = ТИХИЙ no-op (memseed без валидации рода) — при стройке добавить seed-lint; «decl.invariant достаточно» закрывает только несклоняемость, не согласование | бэкенд/владелец | когда-нибудь (со следующей курацией сида) | отдельное решение | D39.21, D39.66 | | 85 | Пилот-преп добор (к переупаковке PACKAGE4): Bertalign GOLD-пары + zh→ru spot-check (D29.2в; GPL-тулинг отдельно) · fidelity-каскад R2 shadow с корпус-подготовкой (D25.2) · exp08 v3 пере-съём COGS до фиксации budget_usd (D21.8) | полигон | когда-нибудь (Ф2.5) | пилот-преп-промт | D25, D29, D21.8, D39.66 | | **— ПАК-21 «чекеры»: РАСТВОРЁН (D39.62); шестёрка исполнена 2/6 фазой 2 (D39.64: #11 · строка 26), остаток — ниже —** | | | | | | | 25 | Остаток дефектов чекеров (4 позиции, флаг §5.5 фазы-2): атрибуция K4b · inner_marker-вето-на-тире · приёмка гомоглиф-детектора (функционально ЕСТЬ — `sanitizer.go:431` `tk.mixed`) · строгий OffLanguage (оговорка 36а) — K-рубрика, корректность только против размеченного корпуса `~/books/gu-zhenren/labels/` (на стенде ЕСТЬ; харнесс замера утрачен в scratch — пересобрать) ПОЛНЫЙ ОСТАТОК (свип D39.66): корзина II целиком — #3 chevron-речь · #5 K5a-предохранитель (数-магнитуды) · #6 заглавная латиница/маркеры TM-BANK · #7 порог-3 (судьбу решить по labels + Р4-контракт) · #10 однознаковые подстрочные ключи (转) — плюс #1 K5c-супрессор пер-вхождением + имплементация Р2-контракта в детекторах (D39.63) · #8 两→cheng_re (данные, батчем) · резидуал R4 пака-11 (DC7 · omission-бэкстоп · DC2 word↔word) · Problem-классы research/22 §2 №5 · alignment-защита авторских рефренов (04-unhappy §7) | бэкенд | в работе (промт выдан D39.68; свежий прогон НЕ нужен, labels/ статичен) | `BACKEND_CHECKER_PACKAGE_SESSION_PROMPT.md` | D39.62, D39.64, GENERALITY_PHASE2 §5.5 | -| 27 | Пост-чек обязан принимать сокращённую форму терма как поле записи (Р3/Q4) | бэкенд | скоро | строка 73 (арка банка, вместе с Decl-швом) | D39.44 Q4, D39.62 | | 28 | Банк-линт латиницы в dst или строгая форма языкового предиката (7 строк утечки алфавита проходят экран) | бэкенд | скоро | пакет-чекеров при свежем мини-прогоне (флаг §5.5; помнить оговорку 36а: «甲等 → класс Цзя» строгой формой не ловится) | D39.52, D39.62, D39.64 | | 28а | Prompt-injection-проба входного текста ($0): сепаратор ⟦TM-BANK-v1⟧ и якорь-подобные маркеры В ТЕКСТЕ КНИГИ — поведение среза/парсера/инъекции (книга = недоверенные данные; инструментов у моделей нет, но канал банкноты читает вывод по маркеру) | бэкенд/полигон | когда-нибудь | малая проба | сводка-ревью 26.07 | | **— СВИП ГИПОТЕЗ —** | | | | | | @@ -61,9 +60,7 @@ | 34а | Экран кодировки не видит `` без XML-объявления (епаб с gb18030-метой прочтётся как UTF-8) — до-паковая дыра, сознательно не тронута пачкой 14а (паритет со старым ридером) | бэкенд | когда-нибудь | отдельное решение | D39.54, SMALLPACK §8 | | 35 | Не-CJK майнер/банк: en-детектора не существует (hanRuns=0), ja с zh-таблицами активно неверен (0/10); нужен второй ДЕТЕКТОР (прототип `mine_nonhan.py`) + G1–G10 требования generic-майнера (вкл. квадратичность G9 ≈4.6 ч) | бэкенд | когда-нибудь | отдельный пак generic-майнера (с ja→ru) | D39.37(8), D39.43, D39.50 п.8, POLYGON_PREMEASURE §6 | | 36 | Кластеризация майнера глотает родовые титулы (族长/学堂家老 в кластере 葛家) — закрыто на границе сборки входа, сама кластеризация не чинилась | бэкенд | когда-нибудь | отдельное решение | D39.43, D39.45 | -| 36а | Валидация поля `type` терма — ПРОМЕРЕНО (D39.65): ненадёжен 12–22%, place ~61%, 2 строгих branch-harm 元石/元海; фикс проверен живьём — сфокусированный классификатор той же моделью чинит 6/6 harm-направления; дизайн-кандидаты: тип-эмиссия терминологом из контекста + $0-экран «name/place с многословным строчным dst» | бэкенд | скоро (вход дизайн-пака 73) | дизайн-пак арки банка | D39.58, D39.65 | | 36б | Второе мнение по банку: рецензент ДРУГОЙ моделью, несогласия — отдельной колонкой подписной таблицы (судья-с-декоем в роли рецензента; решает владелец, D39.46-ограничение не бьёт) | бэкенд/полигон | когда-нибудь | отдельный дизайн (Р4) | D39.58, D39.51 п.4 | -| 36в | Серийная консистентность — ПРИЧИННО показана граница батча (甲等«класс»/батч-5 vs 乙丙丁等«ранг»/батч-0) и фикс живьём (D39.65): со-батчинг серии = согласованно+детерминированно, серий-зависим (转 no-op); реализация без пар-литералов — кластер по общей исходной морфеме; канон-пин родового слова между книгами — отдельный дизайн | бэкенд | скоро (вход дизайн-пака 73) | дизайн-пак арки банка | D39.58, D39.65 | | 36г | Проводной формат каналов пересказан прозой в каждом пар-промпте, парсер в Go, связи нет — формат в шаблон движка (`{{banknote_format}}`); линты держат класс, дублирование остаётся | бэкенд | когда-нибудь (со следующей правкой промптов) | отдельное решение (Р5) | D39.58 | | 37 | Квадратичности: `AttachKWIC` (16.8 с / 2000 кандидатов; фикс — многошаблонный поиск) + src-правило банкноты при промахе чанк-пути (строка × длина книги; на 10 главах не видна) | бэкенд | когда-нибудь | отдельное решение | D39.45, PACK20_BANK_BUILD §8.5.1, D39.58 | | 38 | Смета не видит пере-покупку прохода терминолога на каждой итерации подписи (роль не пишет `chunk_status`, не видна `projectRebill`) | бэкенд | когда-нибудь | отдельное решение | D39.45, PACK20_BANK_BUILD §8.5.2 | @@ -106,6 +103,10 @@ | 69 | Gemini API «под-18» обязательство на конечный продукт (независимо от лейблов) | владелец | когда-нибудь | Ф3 / отдельное решение | D39.32, D39.34(7) | | 70 | Action-security gate перед выдачей tools/webfetch (D25 п.5) | бэкенд | когда-нибудь | Ф3 | D39.34(7) | | 71 | Планы research/22: epub-tag-rewrite · Q7-леджер ; + F3-brief из D29.3 (chat-edit · Not-useful-петля · cost-of-fix) + Ф3-скоуп 02-mvp-plan (TMX/TBX · Bertalign · дистилляция 7–14B) | бэкенд | когда-нибудь | Ф3 | D39.34(7) | +## Оркестратор №9 + бэкенд — КАЧЕСТВО БАНКА ПРИНЯТО И ЗАЛЕНДЕНО ОБОИМИ ЭТАПАМИ (D39.69), 02.08 + +Отчёт: [`archive/reports/BANK_QUALITY_DESIGN_2026-08-01.md`](archive/reports/BANK_QUALITY_DESIGN_2026-08-01.md) (ревью-шапка обоих этапов). Процесс: этап 1 ратифицирован владельцем напрямую; этап 2 до чекеров (не стартовали — коллизий нет); отдельного отчёта стройки нет — компенсировано приёмкой исполнением. Верификация: тесты/парити/голден зелёные; голден 172/172 hex-only независимой маскировкой; «4 серии на живом BANK-FULL» воспроизведено независимо; 4-агентное адверсариальное ревью. Закрыты строки 36а/36в/27/84/73 (+feed_cap из 86); строки 5/18/80 перевязаны на замер/гейт; новые 87 (фикс-лист, несущее «ь» в decl_suffix) и 88 (вопросы Q1–Q8, вкл. ⚠книжные каноны в classifier.md). Отступление стройки от дизайна по оси neuter ПРИНЯТО как улучшение (scoped-фолд по §6.2). + ## Оркестратор №9 — ДВА БЭКЕНД-ПРОМТА ВЫДАНЫ (D39.68): банк-качество (дизайн у бэкендеров) ∥ пакет-чекеров, 01.08 Хендофф по слову владельца («дизайна от тебя не жду — передай бэкендерам; главное правильные промты»): `BACKEND_BANK_QUALITY_SESSION_PROMPT.md` (этап 1 ресёрч+дизайн $0 → СТОП на ратификацию; этап 2 стройка по релею, после лендинга чекеров) и `BACKEND_CHECKER_PACKAGE_SESSION_PROMPT.md` (стройка: строка 25 целиком + 79; харнесс labels в git; Р2/Р4-контракты; голден-протокол вердикт-двигов). Оба несут НОВЫЕ обязательные блоки: глобальный критерий владельца дословно + МАНДАТ РЕВИЗИИ ПОСЫЛОК (свип собрал долг по «забыли», не по «надо ли» — каждый пункт получает вердикт-тройку посылка/нужность/форма, допустимый исход «не строить»). @@ -154,6 +155,10 @@ Q1 = П1 цель-шов санкционирован («главное бэке ## Бэкенд +**Качество банка — этап 1 (дизайн) исполнен (01.08, бэкенд-сессия по `BACKEND_BANK_QUALITY_SESSION_PROMPT`, $0, read-only, НЕ закоммичено).** Отчёт: [archive/reports/BANK_QUALITY_DESIGN_2026-08-01.md](archive/reports/BANK_QUALITY_DESIGN_2026-08-01.md). 6 пунктов свипа сведены в **3 переиспользуемых примитива** (A head-aware кластер-по-морфеме: §1 серия-батчинг + §6 голова 蛊 · B ре-порядок `terminology.Batch`: §1 + feed_cap · C стеммер целевого письма: §3 Decl-шов + omission-бэкстоп чекеров) + дата-правки (тип — ОТДЕЛЬНЫМ классификатором первичным; род neuter enum+seed-lint, ось `RenderFormatVersion`). **Самопроверка — 3 рубежа (грунтинг + анкор-верификация всех 8 tech-debt-якорей CONFIRMED + 5-агентная адверсариальная дизайн-критика): §1 серия-дефиниция «равная длина/1 позиция» и §2 инлайн-тип были BROKEN — переработаны (§1 → head-aware, пере-верифицировано на `BANK-FULL.tsv`: наивное = 19 кластеров вкл. блоб с §2-harm+протагонист, head-aware = 4 годных серии; §2 → классификатор первичным, DO-роль ДО RenderBatch; $0-экран честно пере-скоупнут — провабельно пропускал юаньши/Юаньхай); §5/§6/§7-эмбеддинг WEAKENED вправлены.** §4 (36б второе мнение) и §7-эмбеддинг ЗАКРЫТЫ по нужности; §5 (резюме-слой 80) → пилот-Ф2.5-гейт (проза-суммарайзер закрыт; «дата-состояние свободно от D1» опровергнуто — source-anchored reveal без верификатора); §6-эмиссия головы → полигон-замер + после #10 (seed-половина строится). **First-occurrence-тест ИСПОЛНЕН** ($0, `eval/bank_autonomy/m_firstocc.py`): гипотеза первенства ОПРОВЕРГНУТА (финал==первое 7 == финал==частота 7, большинство — ни то ни другое) ⇒ роль = контекстный ре-решатель, усиливает §1 (со-батч — единственный рычаг). **7 вопросов владельцу** (§9: канон-пин между прогонами · замер 36б · диспозиция резюме · hard-инъективность · вкус авто-транслита реалий юаньци/цзин-ци-шэнь · короткая форма 27 · замер эмиссии головы). **СТОП — ратификация оркестратора/владельца; этап 2 (стройка) — по релею после лендинга пакета-чекеров.** → **РАТИФИЦИРОВАНО ВЛАДЕЛЬЦЕМ, ЭТАП 2 (СТРОЙКА) ИСПОЛНЕН (01.08, та же сессия, $0 — только фейк-провайдер в тестах, НЕ закоммичено, resnapshot НЕ запускался).** + +**Качество банка — этап 2 (стройка) исполнен.** Построены 3 примитива дизайна: **§1 серия-батчинг** (`terminology/series.go` `DetectSeries` head-aware + `Batch` серия-целиком + value-порядок feed_cap; пар-данные `lang.SeriesMorphology` из CJK-письма — инертно для алфавита; **пере-верифицировано на живом `BANK-FULL.tsv`: ровно 4 годных серии, ноль блоба**). **§2 тип-классификатор первичным** (новая фаза перед рендером на общем `runBankRoleBatches` — денежный цикл терминолога зарефакторен в общий, чекпойнт-хеш идентичен; корректный тип роутит conform + праймит RenderBatch + банкуется через `attachClassifiedType` БЕЗ смены эмиссии=без реоткрытия D39.50; своя роль/бюджет/промт `classifier.md`; честный $0 label-экран). **§3 Decl-шов** (`lang.TargetStemmer` примитив C + ru `decl_suffix` данные → `dstFormPresent` принимает косвенные падежи без перечисления форм; **neuter** enum→`injection.txt`/`genderConstraintNote`; **SeedLint** валидирует род громко; ось — **scoped `hasNeuter`-фолд** в `memory_version` по образцу `editor-unverified-section-v1`, НЕ бланкетный `RenderFormatVersion`). **Верификация (5 независимых сигналов):** `go test ./...` зелёно · `-race` чисто на затронутых пакетах · греп-общность: ноль пар/книго-литералов в Go-логике (новая пара — без правки Go) · голден Ш-1 **пере-захвачен, 172/172 hex-only = вердикт-нейтрально** (EmbeddedDataVersion сдвинулся от данных injection/target-ru) · **адверсариальное ревью author≠reviewer (4 измерения × refute-verify): 0 подтверждённых дефектов.** Короткая форма (27) — уже работает через `decl.forms[]`, кода не требует (§9-Q6 владельцу открыт). §4/§5/§6-эмиссия/§7-эмбеддинг — как в дизайне (не строятся/гейтнуты). + **Общность фаза 2 исполнена (01.08, бэкенд-сессия по `BACKEND_GENERALITY_PHASE2_SESSION_PROMPT`, $0, НЕ закоммичено, resnapshot НЕ запускался).** Отчёт: [archive/reports/GENERALITY_PHASE2_2026-08-01.md](archive/reports/GENERALITY_PHASE2_2026-08-01.md). Построено+верифицировано исполнением: **П0** байты-есть-версия (`lang.EmbeddedVersion()`→снапшот, мут.тест) · **П1** цель-шов слоя 7 (isRuTarget×6→`TargetActive`/`TargetScriptNonLatin` по данным · токенизатор цели с алфавитом из данных + U+0301 = ЕДИНЫЙ шов, репэйнут долг `repair.go:182-189` + закрыт #11 · ExportNormalize-гейт · ruSanitizer→per-run поле Checkers · реестр целей из эмбед-данных) · **П2** скрипт-шов источника (`cjkShare`→`sourceScriptShare` по ОБЪЯВЛЕННОМУ письму + новый `lang-script.txt`/`IsCJKScriptLang` · Hangul в CJK-leak — **живой баг ko/строка 75 ЗАКРЫТ**) · **П3** 話-глава в данные · sourceAbbrevs по языку книги · generic-детект глав (Chapter 12, opt units) · консолидация класса письма (5 сайтов, вторая копия арифметики на шов) · **П4** табличный опц-файл + манифест-по-каналам (обратно-совместим, БЕЗ бампа `packAlgoVersion` — снят самый жёсткий блокер слоя данных) · шестёрка: translitInterjections→данные + #11 · малая пачка: #77 ре-ген-эхо-ручка (дефолт=текущее). **Верификация:** `go test -race ./...` 14/14 · голден Ш-1 **0 verdict/wire / 172 version-only** (вердикт-нейтрально по всему пакету) · майнер-парити EXACT (`n=13618`, катастрофы точны, recall 0.9655) · мут.тесты П0-байт/П1-инертность-en/П2-Hangul PASS · сухой-прогон 2-й пары PASS. Сдвинуто 5 снапшот-осей (`embedded_version` нов + CheapGate/Sanitizer/classifier/chunker бампы) — перекупок НЕТ (D39.63). **10 позиций флагнуто на владельца** (ё-фолд · segmentation-громкая ⚠расходится с рат.тестом `prompt_pack_test.go:193` · EstimateTokens-N-коэфф · encoding-аллоулист · 4 дефекта пака-21 нужен ВНЕ-git размеченный корпус · #78 money/ledger-путь) — §5 отчёта, каждый с причиной+дизайном. **СТОП — приёмка оркестратора.** → **ПРИНЯТО И ЗАЛЕНДЕНО (D39.64, ревью-шапка в отчёте; журнал выше).** *(Закрытая хроника пакетов №0–№4 (04–10.07) перенесена в [archive/PROGRESS-2026-07-04-10.md](archive/PROGRESS-2026-07-04-10.md) — ревизия D31.)* diff --git a/docs/README.md b/docs/README.md index 7f603cea..0d74bff6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,17 +11,17 @@ ## Структура -- `architecture/` — синтез. **Источник истины по решениям — [`05-decisions-log.md`](architecture/05-decisions-log.md) (D1–D39.68); при конфликте с любым доком он выше.** +- `architecture/` — синтез. **Источник истины по решениям — [`05-decisions-log.md`](architecture/05-decisions-log.md) (D1–D39.69); при конфликте с любым доком он выше.** - `01-decisions.md` — принципы Р1–Р10; `02-mvp-plan.md` — фазы и приёмка (v3, 09.07); `03-implementation-notes.md` — контракты Фазы 0; `04-unhappy-paths.md` — ~70 режимов отказа → механизм; `06-memory-risk-registry.md` — реестр рисков банка памяти; **[`09-target-architecture.md`](architecture/09-target-architecture.md) — целевая 7-слойная архитектура (D39; статус стройки — шапка-таблица; инвариант общности §0.1)** · [`10-prompt-architecture.md`](architecture/10-prompt-architecture.md) — консолидированная промпт-заметка (концерн 4) · [`12-go-style-notes.md`](architecture/12-go-style-notes.md) — норматив общности §0 + Go-ответы · [`13-tech-debt-anchors.md`](architecture/13-tech-debt-anchors.md) — якоря техдолга (справочник к бэклогу, НЕ трекер). **Исполненные (архив 25.07, санкция владельца, → `archive/architecture/`):** [`07-strategic-review.md`](archive/architecture/07-strategic-review.md) (стратаудит 09.07 — курс исполнен) · [`08-sync-audit-ledger.md`](archive/architecture/08-sync-audit-ledger.md) (ледджер синк-аудита — отработан) · [`11-implementation-plan.md`](archive/architecture/11-implementation-plan.md) (план пака-11 — исполнен целиком); `components.puml`/`pipeline.puml` — диаграммы (перерисованы 25.07 под пост-пак-16 реальность: волновой исполнитель, пакетный сплит пака-15, repair-петля за `enabled:false`, generic content-labels D39.25; владелец смотрит PlantUML-расширением VS Code; вручную НЕ рендерить). - `experiments/` — эмпирика «Полигона»: `00-provider-quirks` (читать перед любым вызовом провайдера), `01-token-calibration`, `02-refusal-benchmark`, `03-local-stand`, `04-editor-quality`, `06-local-extraction`, `07-coverage-precision`, `08-cost-model-v2` (актуальная денежная модель), `09-pilot-protocol` (пилот Ф2.5 + поправки D13), `10-explicit-benchmark` (18+ violence-рука канала B), `11-erotica-benchmark` (erotica по трём парам/регистрам — закрытие D14.4, D22), `12-quality-diagnosis`/`13-translator-bakeoff`/`14-quality-empirics`/`14b-meaning-battery` (дуга качества «мерить→строить», закрыты → D30/D32/D37/D38), `15-segmentation-empirics` (exp15: когезия под floor-шумом, фертильность — закрыт → D39.7/D39.8), `16-bank-mining` (exp16: WHICH/WHAT банк-майнинга — закрыт → D39.10). - `research/` — фактура исследований 04–05.07: `01–10` базовые, `11-gap-*` добор критиком, `12-*` режимы отказа/отзывы/таксономии (+ два внешних материала с провенанс-шапками), `13` валидация памяти, `14` адаптивная память, `15` голос и состояние (принят, D21), **`16` ридер-IDE (принят с ревью-шапкой, D29)**, **`17` внешняя критика GPT-5.6 (принят с ревью-шапкой, D25)** — у 16/17 читать шапку прежде тела. **`18` рычаги качества (два отчёта, D36)** · **`19` нарезка+когезия+контракт t/e (D39.1)** · **`20` банк-майнинг W1.5 (D39.6)** · **`21` обзор LLM-транспорта чужих харнессов (23.07: наш транспорт опережает/вровень со всеми 11)** · **`22` доменные харнессы перевода (24.07: калибрующий — ядро подтверждено, впереди COGS/18+/общность/измеренность, позади Q4-выпуск и gate+repair-петля; сиквел `02`/`05`)** — у всех ревью-шапки. ⚠ Часть под superseded-баннерами (01/02/03/04/05/09 и gap-1/2/5) — **читай баннер прежде содержимого**. - `PROGRESS.md` — **журнал** (CURRENT-STATE сверху, ниже хронология; НЕ источник решений). -- **Активные хендофф-промты (01.08; всё остальное — в `archive/prompts/` с баннерами-исходами):** [`ORCHESTRATOR_SESSION_PROMPT.md`](ORCHESTRATOR_SESSION_PROMPT.md) (роль/нормы; состояние НЕ дублирует) · [`BACKEND_BANK_QUALITY_SESSION_PROMPT.md`](BACKEND_BANK_QUALITY_SESSION_PROMPT.md) (**ТЕКУЩИЙ бэкендный №1**: качество банка, этап 1 дизайн → СТОП) · [`BACKEND_CHECKER_PACKAGE_SESSION_PROMPT.md`](BACKEND_CHECKER_PACKAGE_SESSION_PROMPT.md) (**ТЕКУЩИЙ бэкендный №2**: пакет-чекеров против labels) · [`POLYGON_PACKAGE4_SESSION_PROMPT.md`](POLYGON_PACKAGE4_SESSION_PROMPT.md) (residual-трекер пилота/18+/echo, отложен; P4-хвост из него закрыт D39.61). Закрытые 30.07–01.08 (промты в `archive/prompts/`, отчёты с ревью-шапками в `archive/reports/`): пак-19 «голос/состояние» (D39.54–56) · холодный мини-прогон (D39.58) · ToS-речек (D39.57) · ресёрч общности фаза 1 (D39.60) · полигон Р6+P4 (D39.61) · **фаза 2 общности (D39.64)** · **полигон «сырьё арки банка»+пробы (D39.65)**. +- **Активные хендофф-промты (01.08; всё остальное — в `archive/prompts/` с баннерами-исходами):** [`ORCHESTRATOR_SESSION_PROMPT.md`](ORCHESTRATOR_SESSION_PROMPT.md) (роль/нормы; состояние НЕ дублирует) · [`BACKEND_CHECKER_PACKAGE_SESSION_PROMPT.md`](BACKEND_CHECKER_PACKAGE_SESSION_PROMPT.md) (**ТЕКУЩИЙ бэкендный**: пакет-чекеров против labels; сессия ещё не запущена) · [`POLYGON_PACKAGE4_SESSION_PROMPT.md`](POLYGON_PACKAGE4_SESSION_PROMPT.md) (residual-трекер пилота/18+/echo, отложен; P4-хвост из него закрыт D39.61). Закрытые 30.07–01.08 (промты в `archive/prompts/`, отчёты с ревью-шапками в `archive/reports/`): пак-19 «голос/состояние» (D39.54–56) · холодный мини-прогон (D39.58) · ToS-речек (D39.57) · ресёрч общности фаза 1 (D39.60) · полигон Р6+P4 (D39.61) · **фаза 2 общности (D39.64)** · **полигон «сырьё арки банка»+пробы (D39.65)** · **качество банка, оба этапа (D39.69)**. - `archive/` — `prompts/`: закрытые сессионные промты (только история, инструкции оттуда не исполнять) · `reports/`: отчёты паков/ресёрч-сессий с ревью-шапками (живая фактура приёмок — на них ссылаются D-лог и активные промты) · `architecture/`: исполненные арх-доки (07 стратревью · 08 синк-ледджер · 11 план пака-11) · четыре слайса хроники `PROGRESS-2026-07-{04-10,10-13,13-25,25-31}.md`. -## Статус (2026-08-01, голова D39.68 — эра «общность → качество банка») +## Статус (2026-08-01, голова D39.69 — эра «общность → качество банка») -Полная карта состояния НЕ здесь: фазы/курс/очередь/горизонт — в шапке [`PROGRESS.md`](PROGRESS.md) (CURRENT-STATE + единый бэклог), решения — в [`architecture/05-decisions-log.md`](architecture/05-decisions-log.md) (карта актуальности; свежая голова — с хвоста файла). Коротко: Ф0/Ф1 ✅ · арх-ресет D39 (паки 11–16) ✅ · паки 17–20 ✅ · мини-прогон и холодный прогон приняты (D39.37/58) · карта языковой привязки движка снята (D39.60: книго-ось чиста; ja→ru безопасна без правки Go, en→ru — нет) · Р1–Р4 закрыты (D39.63) · пак-21 растворён (D39.62) · **фаза 2 общности принята (D39.64: цель/скрипт-швы по данным, эмбед-хеш, манифест-по-каналам; голден вердикт-нейтрален)**. Полигон-ветка закрыта D39.65 (строки 21/22, фиксы 36а/36в проверены живьём). Текущее: два бэкенд-промта выданы (D39.68) — банк-качество (дизайн у бэкендеров, этап 1) ∥ пакет-чекеров; оба с мандатом ревизии посылок. Свип полноты исполнен и пост-сверен кодом (D39.66: 951 обязательство, потери возвращены; якоря — architecture/13). ⚠ DeepSeek-0731 сменил веса под слагом — платные прогоны СТОП до ре-пробы (бэклог-строка 74). Дальше: стройка банка ∥ пакет-чекеров → ре-проба flash → ДОБОР ИДЕАЛА (мини-прогон и свип гипотез влиты, D39.67) → вторая пара (ja→ru) → МАСШТАБ → пилот Ф2.5 → Ф3 ридер-IDE. +Полная карта состояния НЕ здесь: фазы/курс/очередь/горизонт — в шапке [`PROGRESS.md`](PROGRESS.md) (CURRENT-STATE + единый бэклог), решения — в [`architecture/05-decisions-log.md`](architecture/05-decisions-log.md) (карта актуальности; свежая голова — с хвоста файла). Коротко: Ф0/Ф1 ✅ · арх-ресет D39 (паки 11–16) ✅ · паки 17–20 ✅ · мини-прогон и холодный прогон приняты (D39.37/58) · карта языковой привязки движка снята (D39.60: книго-ось чиста; ja→ru безопасна без правки Go, en→ru — нет) · Р1–Р4 закрыты (D39.63) · пак-21 растворён (D39.62) · **фаза 2 общности принята (D39.64: цель/скрипт-швы по данным, эмбед-хеш, манифест-по-каналам; голден вердикт-нейтрален)**. Полигон-ветка закрыта D39.65 (строки 21/22, фиксы 36а/36в проверены живьём). Банк-пак принят целиком (D39.69: серия-батчинг · тип-классификатор · Decl/neuter). Текущее: пакет-чекеров (промт выдан, не запущен) + фикс-лист 87 + вопросы владельцу 88. Свип полноты исполнен и пост-сверен кодом (D39.66: 951 обязательство, потери возвращены; якоря — architecture/13). ⚠ DeepSeek-0731 сменил веса под слагом — платные прогоны СТОП до ре-пробы (бэклог-строка 74). Дальше: стройка банка ∥ пакет-чекеров → ре-проба flash → ДОБОР ИДЕАЛА (мини-прогон и свип гипотез влиты, D39.67) → вторая пара (ja→ru) → МАСШТАБ → пилот Ф2.5 → Ф3 ридер-IDE. ## Доступные ключи от моделей DEEPSEEK_API_KEY, ZAI_API_KEY, KIMI_API_KEY, OPENAI_API_KEY, GEMINI_API_KEY, XAI_API_KEY, MISTRAL_API_KEY diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index a143aa8f..4dbffac2 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -1,4 +1,4 @@ -# Журнал решений оркестратора — контракт D1–D39.68 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.07) +# Журнал решений оркестратора — контракт D1–D39.69 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.07) > **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто: > ⚠ **Навигация (актуализация 01.08):** карта ниже детально покрывает D1–D39.28; решения D39.29–63 живут хронологически в теле файла, **свежая голова — С ХВОСТА** (новые ноты аппендятся вниз). Сводка текущей головы и очередь — CURRENT-STATE в `../PROGRESS.md`. @@ -1213,3 +1213,5 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу ## D39.67 — ПЕРЕИМЕНОВАНИЕ КУРСА + ХИРУРГИЯ ОЧЕРЕДИ (решения владельца 01.08): **(1) термин «автономность банка» ЗАМЕНЁН на «КАЧЕСТВО БАНКА»** (формула владельца: «какая разница, автономен ли банк — не подсовывать же плохой банк на подпись, чтобы владелец всё исправлял»; существо прежнее: банк обязан приходить ВЕРНЫМ без ручной правки, подпись опциональна — D39.59; исторические D-ноты не переписываются, живые доки переведены); **(2) отдельный шаг «свежий мини-прогон» РАСТВОРЁН в ДОБОР ИДЕАЛА** (предложение владельца, рекомендация оркестратора совпала: его оси — голос 24 · авто-режим · итерация №2 редакторов 65 · полная цена редактуры 16 — и есть первая фаза добора идеала; собственного merit у отдельного прогона не было); носители строк 16/24/13б/65 перевязаны на «первый прогон добора идеала»; **(3) бэкенд-очередь после дизайн-пака КАЧЕСТВА БАНКА: СТРОЙКА банка ∥ ПАКЕТ-ЧЕКЕРОВ (строка 25 — вес поднят до «скоро»)** — обе кучки $0-код и исполняются, пока платные прогоны стоят на развилке 74; свежий прогон пакету-чекеров НЕ нужен: labels/-корпус статичен, харнесс пересобрать по README в `~/books/gu-zhenren/labels/` (урок scratch); **(4) рабочая бумага пост-сверки ПЕРЕЕХАЛА** из `archive/reports/` в **`docs/architecture/13-tech-debt-anchors.md`** — живой СПРАВОЧНИК якорей к строкам бэклога (НЕ трекер: единственный трекер — таблица PROGRESS; вопрос владельца «архив = неактуально?» резолвлен разделением: архив-отчёты = фактура приёмок, справочник долга = architecture/) (01.08, оркестратор №9). ✅ ## D39.68 — ХЕНДОФФ КАЧЕСТВА БАНКА И ПАКЕТА-ЧЕКЕРОВ БЭКЕНД-СЕССИЯМ (решение владельца 01.08: «дизайна от оркестратора не жду — передать бэкендерам ресёрч → алгоритмы → реализацию → ревью; главное — правильные промты»): выданы ДВА промта — **`BACKEND_BANK_QUALITY_SESSION_PROMPT.md`** (двухфазный: этап 1 = ресёрч+дизайн $0 read-only с деливераблом-отчётом и СТОП на ратификацию — серия-батчинг по морфеме · тип-пере-вывод · Decl-шов+строка 27+84 · честное оспаривание 36б · рекомендация по резюме-слою 80 · дыра 蛊 18 · быстрые вердикты кандидатов вкл. first-occurrence-тест $0; этап 2 = стройка ТОЛЬКО по релею ратификации и после лендинга чекеров — общее дерево) и **`BACKEND_CHECKER_PACKAGE_SESSION_PROMPT.md`** (стройка сразу: полный перечень строки 25 + строка 79; харнесс labels пересобрать В GIT по образцу майнер-парити; Р2/Р4-контракты D39.63 обязательны — hard=значение/soft=форма, потолки в контракт правила; вердикт-двиги голдена для чекер-класса ратифицированы промтом — пере-захват один раз с маскированным диффом и вскрытыми единицами). **ОБА промта несут два новых обязательных блока (нормы владельца 01.08): (1) «Глобальный критерий» дословно — чистый архитектурный бэкенд, который легко писать и поддерживать и который качественно выполняет задачу; общность; механизм только где несёт качество/деньги; (2) «МАНДАТ РЕВИЗИИ ПОСЫЛОК» — свип D39.66 собрал долг по критерию «забыли», НЕ по критерию «надо ли»: каждый пункт перед кодом получает вердикт-тройку (посылка: источник и актуальность · нужность: что даёт на данных, иначе «закрыть» · форма: лучшая из ≥2 альтернатив по критерию), допустимый исход = «не строить», замер против посылки = СТОП и пинг.** Обе кучки $0 — исполняются, пока платные прогоны стоят на развилке 74 (01.08, оркестратор №9). ✅ + +## D39.69 — КАЧЕСТВО БАНКА: ОБА ЭТАПА ПРИНЯТЫ И ЗАЛЕНДЕНЫ (этап 1 дизайн ратифицирован ВЛАДЕЛЬЦЕМ напрямую; этап 2 стройка той же сессией, $0, чекер-пак ещё не стартовал — коллизий нет; приёмка оркестратора: тесты/парити/голден исполнением + независимое воспроизведение флагманских чисел + 4-агентное адверсариальное ревью): **ПОСТРОЕНО ТРИ ПРИМИТИВА** — **§1 серия-батчинг (36в ЗАКРЫТА)**: `DetectSeries` HEAD-AWARE (равная длина · общая голова · различие только в модификаторе · L=1 вне · без транзитивного замыкания) + серия ЦЕЛИКОМ в батч + value-порядок отреза бюджета (частота-desc — **feed_cap ЗАКРЫТ этим**, из строки 86 изъят); канал по классу письма из данных (`lang-script.txt`), алфавитный источник инертен; «ровно 4 серии на живом BANK-FULL, ноль блоба, 方源/元石/元海 вне» — воспроизведено оркестратором НЕЗАВИСИМО; **§2 тип-классификатор ПЕРВИЧНЫМ (36а ЗАКРЫТА)**: отдельная фокус-фаза ДО RenderBatch (единственная форма с живой пробой 6/6), корректный тип роутит conform + праймит рендер + банкуется `attachClassifiedType` БЕЗ смены эмиссии (D39.50 не реоткрыт); своя роль/бюджет/промт `classifier.md`, request-hash-ось, fail-loud пары без промпта; честный $0-экран (тест ПИНИТ, что он пропускает юаньши/Юаньхай — флаг, не гейт); денежный цикл терминолога зарефакторен в общий `runBankRoleBatches` (resume $0 запинен тестом); **§3 Decl-шов (строки 27/84 ЗАКРЫТЫ)**: `lang.TargetStemmer` из ru-ДАННЫХ `decl_suffix` (пара без данных инертна) → `dstFormPresent` принимает косвенные падежи; короткая форма 27 — уже работала через `decl.forms[]` (пин пре-существующим тестом); neuter end-to-end (enum · `gender_neuter` в injection-данных · SeedLint громко отбраковывает неизвестный род); ось = **scoped `neuter-directive-v1`-фолд в `memory_version`** (отступление от дизайна ПРИНЯТО как улучшение — точнее правила условного фолда §6.2, книга без neuter байт-идентична, голден доказал). **ЗАКРЫТО ПО НУЖНОСТИ дизайном (ратификация закрытий):** 36б второе мнение — бэкенд-стройки НЕТ, маршрут = полигон-замер (Q2, строка 5 перевязана) · резюме-слой (строка 80) — проза-суммарайзер закрыт, строка = ГЕЙТ ПИЛОТА Ф2.5 («допускает ли нарратив-строка детерминированный верификатор — или это D1 со схемой») · эмиссия головы 蛊 — сид-половина (чек-лист) + измер-гейт Q7 ПОСЛЕ #10 (строка 18 перевязана) · эмбеддинг A7/D2 — закрыт по нужности, реопен = измеренный recall-разрыв на МАСШТАБЕ, только batch-time лейн · STITCH — закрыт до давления МАСШТАБА (реопен = бюджет ИЛИ измеренный эффект размера инъекции) · ranked-set — качество закрыто §1, hard-режим = Q4. **First-occurrence-тест ($0, исполнен): гипотеза первенства ОПРОВЕРГНУТА** — роль не первовхожденческий и не частотный выбиратель, ре-решает контекстом (позитивное подтверждение D39.65). **Голден: 172/172 hex-only** (сдвиг = `embedded_version` от данных injection/target-ru), вердикты байт-идентичны. **Фикс-лист → строка 87** (несущее: «ь» в `decl_suffix` — приёмочный кейс «Фан Юаню» сейчас ПРОВАЛИВАЕТСЯ; неверный комментарий Batch про независимость request-hash; оверсайз-серия одним батчом — помнить cap-8000; classify-валидация/тесты; мутационный тест neuter; voice-stemmer вопрос). **⚠ Вопрос владельцу Q8: `classifier.md` несёт книжные каноны (方源 и др.) в общий пар-слой** — санкция или жанрово-генерические примеры. Вопросы §9 Q1–Q7 + Q8 — строка 88. Платный порог «6/6 harm» — добрать при ре-пробе 74 (02.08, оркестратор №9). ✅ diff --git a/docs/BACKEND_BANK_QUALITY_SESSION_PROMPT.md b/docs/archive/prompts/BACKEND_BANK_QUALITY_SESSION_PROMPT.md similarity index 95% rename from docs/BACKEND_BANK_QUALITY_SESSION_PROMPT.md rename to docs/archive/prompts/BACKEND_BANK_QUALITY_SESSION_PROMPT.md index ed2b788d..e5b0966e 100644 --- a/docs/BACKEND_BANK_QUALITY_SESSION_PROMPT.md +++ b/docs/archive/prompts/BACKEND_BANK_QUALITY_SESSION_PROMPT.md @@ -1,5 +1,7 @@ # Промт бэкенд-сессии: КАЧЕСТВО БАНКА — этап 1 ресёрч+дизайн ($0), этап 2 стройка (по ратификации) +> **⚠ АРХИВ (02.08.2026): исполнен ОБОИМИ этапами и ПРИНЯТ — D39.69.** Этап 1 ратифицирован владельцем напрямую; этап 2 построен той же сессией. Отчёт с ревью-шапкой: [`../reports/BANK_QUALITY_DESIGN_2026-08-01.md`](../reports/BANK_QUALITY_DESIGN_2026-08-01.md). Фикс-лист — бэклог-строка 87; вопросы владельцу Q1–Q8 — строка 88. Инструкции отсюда НЕ исполнять. + **Выдан 01.08.2026, оркестратор №9; санкция владельца D39.67/68.** Ты — бэкенд-сессия TextMachine. Зона записи: `backend/` + свой отчёт в `docs/archive/reports/`. НЕ коммитить — лендит оркестратор. **Первый деливерабл — эхо-блок ≤10 строк** (первым сообщением ДО работы: как понял скоуп/стоп-точки/$0/зону; продублируй шапкой отчёта; подтверждения не жди — СТОП только в конце этапа). ⚠ **Денег — НОЛЬ.** Платные вызовы моделей запрещены (развилка 74: DeepSeek-0731 покупает пустое, бьёт и терминолога). Все нужные платные пробы УЖЕ куплены и лежат в отчётах — не докупать. `--resnapshot` не запускать. diff --git a/docs/archive/reports/BANK_QUALITY_DESIGN_2026-08-01.md b/docs/archive/reports/BANK_QUALITY_DESIGN_2026-08-01.md new file mode 100644 index 00000000..1fadda21 --- /dev/null +++ b/docs/archive/reports/BANK_QUALITY_DESIGN_2026-08-01.md @@ -0,0 +1,251 @@ +# Дизайн-пак КАЧЕСТВА БАНКА — этап 1 (ресёрч+дизайн, $0) + этап 2 (стройка) + +> **✅ ОБА ЭТАПА ПРИНЯТЫ И ЗАЛЕНДЕНЫ (оркестратор №9, 02.08.2026, D39.69).** Процесс: этап 1 ратифицирован ВЛАДЕЛЬЦЕМ напрямую (без релея оркестратора — его право), этап 2 исполнен той же сессией ДО старта пакета-чекеров (коллизий нет — чекер-сессия не запускалась); отдельного файла-отчёта стройки нет (стройка отчитана записью в PROGRESS §Бэкенд) — компенсировано верификацией оркестратора исполнением. Приёмка: `go build`/`vet`/`-race` 14/14 · майнер-парити EXACT · голден-верификация зелёная · **голден сверен независимой маскировкой: 172/172 строки = только 64-hex хеши + 4 токена `embedded_version`, вердикты/wire/stage_text байт-идентичны** · **«ровно 4 серии на живом BANK-FULL» воспроизведено оркестратором независимо** (копия DetectSeries по TSV: 一…九转·甲乙丙丁等·甲/乙/丙等资质·中/初/高阶; 方源/元石/元海 вне кластеров) · 4-агентное адверсариальное ревью. +> **Расхождения стройки с дизайном:** 2 объявлены и приняты как УЛУЧШЕНИЯ (ось neuter = scoped `neuter-directive-v1`-фолд в `memory_version` вместо бланкетного `RenderFormatVersion` — лучше по правилу условного фолда §6.2; `head_final` = константа CJK-класса при data-driven классе письма); 4 не объявлены → фикс-лист (строка 87): **(1) ГЛАВНОЕ — в `decl_suffix` нет «ь»: приёмочный кейс дизайна «Фан Юаню»→«Фан Юань» эмпирически ПРОВАЛИВАЕТСЯ, палладиевский класс -нь/-ль не матчится (фикс = одна строка данных + пере-захват голдена), тесты стройки тихо пинят «мечом»/«горы» вместо него;** (2) комментарий `Batch` «request hash independent of ordering» неверен (freq-сорт пермутирует ChunkIdx в RequestHash мульти-батчевых книг; сейчас смягчено сдвигом embedded_version); (3) оверсайз-серия кладётся в ОДИН батч сверх бюджета (дизайн читался как разрезание; ре-допускает переполнение — помнить мину cap-8000 терминолога); (4) `classify_types:true` при выключенном terminology-гейте = тихий no-op + ноль config-тестов classify-веток; (5) мутационного теста neuter-фолда нет (пин только косвенный голденом); (6) стеммер в voice-checker не заведён (дизайн-«учесть» = нулевой исход, не объявлен). **⚠ Отдельно на владельца: `prompts/zh-ru/classifier.md` несёт книжные каноны стенд-книги (方源·青茅山·蛊·元石·灵泉·窍·甲等·家老) — первый пар-промт с книжными термами; по норме «книжный термин в общем пар-слое = утечка» нужна либо санкция, либо жанрово-генерические примеры (вопрос Q8).** Платный порог приёмки §2 «воспроизвести 6/6 harm-фикс» не прогнан ($0-мандат) — добрать при ре-пробе развилки 74. + +> **Бэкенд-сессия, 01.08.2026. По промту `docs/BACKEND_BANK_QUALITY_SESSION_PROMPT.md` (D39.68).** Деливерабл этапа 1. Кода в `backend/` не писал; единственное исключение — санкционированный first-occurrence-тест `eval/bank_autonomy/m_firstocc.py` (§7.7, $0 на артефактах coldrun-a). СТОП в конце — на ратификацию. Стройка (этап 2) — только по отдельному релею, после лендинга пакета-чекеров. +> +> **⚠ Этот отчёт прошёл 5-агентную адверсариальную дизайн-критику (author≠reviewer, §8) — 2 решения были BROKEN и переработаны (§1 серия-дефиниция → head-aware; §2 инлайн-тип → классификатор ПЕРВИЧНЫМ), 3 WEAKENED вправлены (§5/§6/§7). Текст ниже — уже ПОСЛЕ правок.** + +## ЭХО-БЛОК (≤10 строк) + +1. **Скоуп:** по каждому механизму — вердикт-тройка (посылка/нужность/форма ≥2 альтернатив) + выбранный дизайн (интерфейсы/данные/снапшот-оси/тесты приёмки); пп.1–6 полная форма, п.7 кандидаты 1–2 строки; блок «вопросы владельцу»; итог 3–5 строк в `docs/PROGRESS.md` §Бэкенд. +2. **Кода не пишу** (кроме `eval/bank_autonomy/m_firstocc.py`, $0 на артефактах coldrun-a). +3. **Деньги — НОЛЬ:** платных вызовов нет, `--resnapshot` не запускаю, пробы не докупаю. +4. **Зона:** read-only по `backend/`; пишу только отчёт + разрешённую тест-папку. Пост-чек #10 однознаковых ключей строит параллельная чекер-сессия — НЕ проектирую заново. +5. **Стоп-точки:** СТОП в конце этапа 1; этап 2 — по релею оркестратора. +6. **Мандат:** ревизия посылок; замер против посылки = СТОП+вопрос владельцу; утверждения о коде грунтованы `file:line`+греп; самопроверка исполнением (грунтинг + анкор-верификация + адверсариальная дизайн-критика — §8). +7. **Универсальность:** решения на уровне чистого арх-бэкенда; Go не ветвится по паре/книге; критерий приёмки «пара, которой нет в репо, проходит без правки Go». + +--- + +## §0. Глобальный критерий и как читать вердикты + +Мерило (владелец 01.08): **чистый архитектурный бэкенд, который легко писать и поддерживать и который качественно выполняет задачу.** Механизм строится только там, где несёт **качество/деньги** — не ради галочки бэклога. Общность: «заработает ли на паре, которой в репо ещё НЕТ, без правки Go?». + +**Курс (D39.59/D39.67):** премиса «решение о переводе = подпись владельца» (D39.42/47) АМЕНДИРОВАНА — банк обязан приходить с максимально качественными **автономными** переводами, подпись опциональна. Рычаг = автономная верность на **холодном** старте (пустой канон), где несущие факторы §C2-3 мертвы, а консолидатор — терминолог по KWIC (D39.65; §7.7 подтвердил ПОЗИТИВНО: роль ре-решает по контексту, игнорируя порядок/частоту). + +**Сквозная находка (несущий тезис чистоты) — после дизайн-критики:** + +| Примитив | Кому служит | Статус | +|---|---|---| +| **A. Кластер по общей исходной морфеме, HEAD-AWARE** | §1 серия-батчинг (СТРОИТЬ) · §6 голова 蛊 (ИЗМЕР-ГЕЙТ) · §7 ranked-set (свёрнут в §1) | §1 строится; §6-эмиссия демотирована до полигон-замера (критика §6) | +| **B. Ре-упорядочивание кандидатов перед батчингом** | §1 серия-смежность · §7 feed_cap | СТРОИТЬ с §1 (одна правка `terminology.Batch`) | +| **C. Лёгкий стеммер целевого письма (accepted-set)** | §3 Decl-шов пост-чека · §7/строка 25 omission-бэкстоп чекеров | СТРОИТЬ, согласованно с чекер-сессией | + +**Строится сейчас:** §1 (head-aware серия + value-порядок B), §2 (классификатор типа ПЕРВИЧНЫМ + честно-скоупнутый $0-экран), §3 (стеммер C + neuter + короткая форма). **Измер-гейт / закрыто:** §4 (36б → полигон-замер), §5 (резюме → пилот Ф2.5), §6 (голова → полигон-замер, seed-половина строится), §7-эмбеддинг (закрыт по нужности). **Все 8 tech-debt-якорей ре-верифицированы адверсариально — CONFIRMED (§8).** + +--- + +## §1. Серия-батчинг (36в) — ПОЛНАЯ ФОРМА · переработана дизайн-критикой (была BROKEN) + +### (а) Посылка +D39.58/D39.65 (замер 21): финальный холодный банк **серийно несогласован причинно на границе батча** — `甲等`→«класс» (батч 5) против `乙丙丁等`→«ранг» (батч 0). Проба §2 `PROBES` (temp 0): со-батч серии → **согласованное+ДЕТЕРМИНИРОВАННОЕ** родовое слово (等: 3 порознь→1 вместе; экстраполяция `丁等`=«четвёртый разряд»), серий-зависим (`转` no-op). §7.7 (исполнено): роль ре-решает по контексту, игнорируя порядок/частоту ⇒ серийную согласованность **нельзя добыть весами/порядком, только со-батчем**. Посылка **верна.** + +**Причинный корень:** `terminology.Batch` (`terminology.go:919`) режет кандидатов по размеру, сохраняя ЛЕКСИКОГРАФИЧЕСКИЙ порядок ключей (`terminology.go:212`). Серия с общей морфемой-СУФФИКСОМ (`等`) сортируется по РАЗЛИЧАЮЩЕМУСЯ ПРЕФИКСУ (`甲乙丙丁`) → раскол. `Related`=только вложенность (`terminology.go:242`); `甲等`/`乙等` не связаны. + +### (б) Нужность +Не делать ⇒ холодный банк серийно-несогласован. Причинность+живой фикс замерены, §7.7 показал: альтернатив (веса/порядок) нет. **Нужно.** + +### (в) Форма — выбрано: **HEAD-AWARE кластер серии + со-батч без раскола (по размеру) + value-порядок (B)** + +> ⚠ **Первичная форма («равная длина, различие в ОДНОЙ позиции») была BROKEN дизайн-критикой и МОЙ собственной верификацией на `BANK-FULL.tsv` (150 поверхностей, $0 read-only):** наивное правило даёт 19 транзитивных кластеров, из них ~4 годных; патология — 11-членный блоб `[侧室,元气,元海,元石,花海,蛊室,蛊师,蛊虫,酒气,酒肆,酒虫]` (содержит ОБА §2-branch-harm юнита `元石/元海`!), `[方家,方正,方源,管家]` (протагонист `方源` freq 189 + управляющий), вырожденный `[点,窍,转]` (одиночные символы). Наивное правило ломает главного героя и УКРЕПЛЯЕТ ровно тот транслит-harm, что чинит §2. **Переопределено ниже.** + +Оперирует над `terminology.Candidate`+`terminology.Batch` (пост-merge; **НЕ трогает майнер-парити** — она на `mr.ranked` до Merge, `miner_parity_test.go:103-118`; concede критика): +1. **Отношение «серия» HEAD-AWARE.** Серия = **≥3 поверхностей РАВНОЙ длины, делящих общую ГОЛОВУ (родовое слово) и различающихся ТОЛЬКО в позиции МОДИФИКАТОРА (не головы)**. Сторона головы = **пар-данные (langpack-булев `head_final`)**: для zh/ja голова — трейлинг-руны, различающаяся руна обязана быть НЕ-финальной. Исключить L=1. **Проверено мной на `BANK-FULL.tsv`:** head-aware даёт РОВНО 4 годных серии — `一…九转`, `中/初/高阶`, `甲乙丙丁等`, `甲/乙/丙等资质` — и корректно роняет `元/蛊/酒`-семьи (различие в ГОЛОВЕ `石/海/室/师` ⇒ РАЗНЫЕ сущности), протагониста, вырожденные одиночки. **Реконсиляция §1↔§2:** `元石/元海` различаются в ГОЛОВЕ ⇒ НЕ серия — они принадлежат §2 (тип-harm), не §1. Пример `元石↔元海` из чернового §1 УДАЛЁН. +2. **Батчинг.** Сорт `(seriesKey, key)` (серия смежна) + **не резать серию границей батча**. **Кап оверсайза — по РЕНДЕР-РАЗМЕРУ, зеркаля `Batch.maxRunes` (`terminologist.go:59` batchRunes=6000), НЕ по числу членов N** (критика: под-N серия с широким KWIC переполнит бюджет; терминолог уже рассуждает 8063 ток у cap, PROBES §1). Серия крупнее бюджета → свои батчи, не роняется. +3. **Value-порядок (примитив B, свёрнут feed_cap):** раз трогаем `Batch`, ОТРЕЗ бюджета — по частоте-desc/типу (наименее частотный хвост, не лексикографический). Закрывает feed_cap (§7) одной правкой. + +**Детерминизм/парити (concede критика):** `terminologyVersion` (лог, не фолд); resume $0; майнер-парити НЕ трогается. Пар-агностичность КОДА держится (руны), но ПРЕЦИЗИЯ скрипт-зависима (общий CJK-символ ≈ морфема; алфавитная минимальная пара care/core — совпадение) ⇒ `head_final`+«серия вкл/выкл» = пар-данные; для не-CJK источника серия-канал по умолчанию OFF данными. + +**Канон-пин родового слова МЕЖДУ прогонами/книгами:** +- Внутри книги, автономно: `neighbourAgrees` считает ТОЛЬКО `approved` (`terminologist.go:187`) ⇒ на неподписанном мёртв. Софт-вес авто-соседа — **вопрос владельцу §9-Q1** (тюнинг фактора против D39.65 «веса на холодном не трогать»). +- Между книгами: носитель есть (экспорт подписанного глоссария как сид, бэклог 48а). Дизайна в арке не требует. + +### Альтернативы (отклонены) +- **«Равная длина, 1 позиция» без head-awareness:** 27/31 (у меня 15/19) ложных кластеров на BANK-FULL, вкл. `方源` и §2-harm — ИЗМЕРЕНО. — **Хардкод `等/转`:** утечка. — **Тюнить веса:** D39.65+§7.7. — **Широкий «≥1 общий Han»:** взрывает семьи. — **Мульти-проход:** D39.65 «не гарантирует», платный. + +### Снапшот-оси +`terminologyVersion` (лог, не двигает `memory_version`). Софт-пин (если строить) — тоже туда. + +### Тесты приёмки +- Юнит: `{甲乙丙丁等,...}` head-aware ⇒ один батч; `元石/元海` (различие в голове) ⇒ НЕ серия; L=1 ⇒ не кластер; серия > бюджета ⇒ свои батчи (по рендер-размеру), не дропнута. +- **Регресс-фикстура из BANK-FULL:** 4 годных серии кластеризуются, 15+ ложных — нет (пин против наивного правила). +- Пар-агностичность: `head_final=false`/не-CJK ⇒ канал инертен без правки Go. +- Голден Ш-1: `terminologyVersion` двинулась; `content_hash`/wire не-серийных термов — нет. + +--- + +## §2. Тип-пере-вывод (36а) — ПОЛНАЯ ФОРМА · переработана дизайн-критикой (была BROKEN) + +### (а) Посылка +D39.65: draft-`type` ненадёжен **12–22%**; **2 строгих branch-harm** `元石`→юаньши, `元海`→Юаньхай. Проба §3: **сфокусированный ОТДЕЛЬНЫЙ классификатор той же модели чинит 6/6 harm** (+~10 п.п.). Посылка **верна.** + +**Поток типа (end-to-end):** источник = эвристика (`c.Types[0]`, `miner_emit.go:197`; `formantType` `miner_patterns.go:65`). Гейт эмиссии `name|place|title` (`miner_emit.go:303`). Ветка транслита: тип роутит conform (`terminologist.go:165-170`→`terminology.go:415-420`); миннер сам не транслитерирует (`PalladiusConformance` только скорит, `miner_palladius.go:106`). `RenderBatch` печатает draft-`type` роли как подсказку (`terminology.go:633`). Барьер: `ParseReply` (`terminology.go:855-915`) читает РОВНО `srcdst`, тип игнорирует (анкор §8: «genuine hard wall — правка И render И parse»). + +### (б) Нужность +Не делать ⇒ реалии транслитерируются («юаньши/Юаньхай») — «жалоба владельца». Защита = ХРУПКОЕ контекст-переопределение роли, УЖЕ провалившееся на 元石/元海 (RAWMATERIAL:162). **Нужно.** + +### (в) Форма — выбрано: **ОТДЕЛЬНЫЙ сфокусированный классификатор типа ПЕРВИЧНЫМ (DO-роль-проход ДО `RenderBatch`)** + +> ⚠ **Первичная форма (инлайн-тип + $0-бэкстоп как пояс) была BROKEN дизайн-критикой:** (1) $0-бэкстоп «name/place с многословным строчным dst → демот» ловит НОЛЬ из 2 замеренных harm — `元石→юаньши` ОДНО строчное слово (не многословный), `元海→Юаньхай` ЗАГЛАВНОЕ (мой же тест-приёмки его не трогал); транслит-направление СТРУКТУРНО невидимо $0-экрану без исходного класса. (2) Инлайн эмитит тип ОДНОВРЕМЕННО с dst — поздно, чтобы разбиасить dst ЭТОГО (холодного = несущего) прогона; `RenderBatch` праймит роль неверным `type:name` до dst. (3) Инлайн воссоздаёт ровно диагноз пробы §3: тип ломается КАК «плохо-сделанная побочная колонка при переводе» (PROBES:46) — инлайн кладёт его обратно побочной колонкой. **Инвертировано:** + +- **Классификатор ПЕРВИЧНЫМ.** Отдельный дешёвый проход-классификатор (та же модель, задача СФОКУСИРОВАНА на классе — единственная проверенная 6/6 форма, PROBES §3) над кандидатами **ДО `RenderBatch`**, свой request-hash-осью как у терминолога/repair (`terminologist.go:46`). Корректированный тип: (i) роутит conform-ранжирование ЭТОГО холодного прогона (не после факта), (ii) праймит `RenderBatch` верным типом (роль видит `type:term` → смещена переводить), (iii) роутит эмиссию-режим. Это ЕДИНСТВЕННАЯ форма с путём предотвратить холодный-run harm. `title` в промте-классификаторе ОБЯЗАН явно включать work-title (стихи/песни; майнер их типирует `term`, нет work-title-канала `miner_patterns.go:194`) — пар-данные. +- **$0-детерминированный экран — ЧЕСТНО ПЕРЕ-СКОУПНУТ.** НЕ «harm-пояс» (он провабельно пропускает `元石→юаньши` строчное и `元海→Юаньхай` заглавное). Его реальная роль — тёплого-прогона **чистка тип-метки**: флаг `name/place`, чей dst был ПЕРЕВЕДЁН (многословный) — расхождение метки для ревью, НЕ safety-net при отказе роли. Записано прямо. + +**COGS:** классификатор = отдельный дешёвый проход по банку (батчи, как терминолог) — цена реальна, но это ПРОВЕРЕННАЯ форма. Инлайн-тип (дешевле) остаётся **кандидатом на COGS-оптимизацию ТОЛЬКО за живым A/B, воспроизводящим 6/6 harm-фикс** до коммита — не принимать на аргументе цены с нефункциональным поясом. + +**Честный лимит (§7.7): §2 НЕ закрывает всю «слишком китаизированно».** Роль АВТОНОМНО сочиняет пиньинь-транслит для КОРРЕКТНО-типированных `term`-реалий (юаньци/чжэньюань/цзин-ци-шэнь) — вне тип-роутинга. §2 чинит 2 строгих name/place→транслит; авто-транслит реалий — вкус/промпт (§9-Q5). + +### Альтернативы (отклонены) +- **Инлайн-тип + $0-бэкстоп:** BROKEN (выше) — оставлен ТОЛЬКО как COGS-кандидат за A/B. — **Только $0-экран:** ловит 0/2 harm. — **Верить draft-типу:** 12–22%. + +### Снапшот-оси +Классификатор — дешёвый проход, своя request-hash-ось (`terminologyVersion`/роль-марка). Тип на строке фолдится в `memory_version` (`memory.go:415`) ⇒ `--resnapshot` (repin) при смене типа строки. + +### Тесты приёмки +- Классификатор: harm-набор (元石/元海/蛊室/池塘/灵泉/酒肆) → `term` (воспроизвести 6/6 PROBES §3 при стройке — приёмочный порог). +- Тип праймит `RenderBatch` ДО dst (юнит: корректированный тип в wire-блоке); conform роутится по корректированному типу. +- $0-экран: явный тест, что он ПРОПУСКАЕТ `元石→юаньши`/`元海→Юаньхай` (честность роли — не safety-net); ловит только переведённый многословный. +- Пар-агностичность: `Conformance=nil` не падает; work-title в промте (данные). + +--- + +## §3. Decl-шов (27+73) + род «гу» (84) — ПОЛНАЯ ФОРМА + +### (а) Посылка +D39.65: **Decl 142/142 null**. D39.60: «майненая сторона не пишет Decl ⇒ 149 авто-строк не пройдут пост-чек». Строка 27 (D39.44 Q4): «сокращённая форма = ПОЛЕ записи; пост-чек обязан принимать». Строка 84 (D39.21): канон neuter без носителя, `gender:neuter` — тихий no-op. Грунтовано: `dstFormPresent` (`mempostcheck.go:144-154`) = базовый dst + `declForms`; пустой decl ⇒ ложные флаги (research/14 18–36%). `genderConstraintNote` (`memory.go:815-825`) без neuter; `SeedLint` (`memseed.go:486`) не валидирует род/тип/decl. + +### (б) Нужность +- **Decl:** банк-КОНТЕНТ не портится — decl нужен ПОСТ-ЧЕКУ (flagger, не hard-gate `mempostcheck.go:17-28`; hard — бэклог 12) ⇒ цена дыры = ШУМ ложных miss. Ургентность СРЕДНЯЯ. **Нужно, не эмиссией форм ролью.** +- **Сокращённая форма (27):** ложный miss на краткой форме. **Нужно** (мелко). +- **Род neuter (84):** без носителя канон неисполним → C3-спойлер-рода (строка 82). **Нужно.** + +### (в) Форма +**Decl — лёгкий стеммер целевого письма в accepted-set (примитив C), НЕ эмиссия ролью.** `dstFormPresent` + форма от консервативного суффикс-стеммера цели (пар-данные: реестр окончаний ru). ТОТ ЖЕ стеммер = omission-бэкстоп чекеров (строка 25 R4) — строить согласованно. Кормит и voice-checker (`memvoice.go:321`) — учесть. Оговорка precision/recall (`mempostcheck.go:130-143`): монотонно (precision↑/recall↓); КОНСЕРВАТИВНЫЙ + замер на `labels/` до hard-gate (связка бэклог-12). + +**Сокращённая форма (27):** дешевле всего — в существующий `decl.forms[]` (0 схемы/миграции, `memory_version` уже фолдит `r.Decl` `memory.go:417`), через тот же `dstFormPresent`. **§9-Q6:** «сокращённая» = морфоклип (forms[] хватает) или прозвище/диминутив (тогда отдельное поле / `nickname_translation`)? + +**Род neuter (84):** `genderConstraintNote`+ветка `neuter`→`tx.GenderNeuter` (новый `gender_neuter` в `injection.txt`, эмбед per-target — пар-данные); `SeedLint` отбраковывает неизвестные `gender`. **Несущая ось = `RenderFormatVersion` (`memory.go:711`), НЕ `memory_version`** (значение `neuter` уже фолдится `memory.go:416` — оно не двигается при начале РЕНДЕРА; новые wire-байты ловит `RenderFormatVersion`) ⇒ `--resnapshot` едет ТАМ. + +### Альтернативы (отклонены) +- Терминолог эмитит decl-формы (целевая морфология, дорого/не работа роли). — Полный pymorphy (тяжёл; для рода-гейта строка 82, для accepted-set избыточен). — Отложить Decl (стеммер дёшев+переиспользуется). + +### Снапшот-оси +Стеммер в `dstFormPresent` — в хеш ТОЛЬКО при hard-gate; как flagger — вне. neuter → `RenderFormatVersion`. Краткая форма в `forms[]` → `memory_version`. + +### Тесты приёмки +- Стеммер: «Фан Юаню»→«Фан Юань» без decl; консервативность на `labels/`. neuter: `gender:neuter`→текст; `gender:xyz`→`SeedLint` ГРОМКО падает; **компат §9-Q6: отбраковывать ли уже-сохранённые neuter-сиды**. + +--- + +## §4. 36б «второе мнение» — ЧЕСТНОЕ ОСПАРИВАНИЕ (закрытие HOLDS по критике) + +### (а) Посылка +Строка 5/36б: рецензент другой моделью, несогласия колонкой подписной таблицы. + +### (б) Нужность — **не строить в арке; закрыть с маршрутом в полигон-замер.** (Критик подтвердил закрытие.) +- Как ранжировщик ОТКЛОНЁН фактом (D39.46: разрыв до декоя 1.27–1.42 против 0.20 — катастроф-экран, не ранжировщик). +- §2 закрывает главный тип-harm. Резидуал: систематика ОДНОГО СЕМЕЙСТВА (draft+терминолог — оба DeepSeek) + сочинённое (§7.7: 8/67 сочинено вне drafts). НО «эффективный ≠ верный» — юаньци/цзин-ци-шэнь возможно-ВЕРНЫ (стандартные транслиты), т.е. рецензент чинил бы возможно-НЕ-дефект. +- Ценность рецензента ДРУГОГО семейства над §2+со-батч не измерена; форма = полигон-эксперимент. + +### (в) Форма +**Закрыть бэкенд-стройку. Маршрут §9-Q2:** полигон-замер нужности рецензента-другого-семейства ДО стройки; переживёт → тонкая колонка наблюдаемости (не гейт). Реальная диспозиция (Q2 роутит КОНКРЕТНЫЙ замер), не пунт. + +--- + +## §5. Резюме-слой памяти (строка 80) — РЕКОМЕНДАЦИЯ · вправлена критикой (WEAKENED) + +### (а) Посылка +Строка 80 (D25 п.8–9, D30.8, 06-реестр D1): резюме глава→арка→книга + fact-gate. Грунтовано: механизма НЕТ (`memoryVersion` = approved-глоссарий+voice+address; `snapshot.go:391` «summary» аспирационно); D30.8 (`лог:373`) отложил в Ф2. + +### (б)+(в) Рекомендация: **ЗАКРЫТЬ ФОРМУ ПЛАТНОГО ПРОЗА-СУММАРАЙЗЕРА; строку 80 держать ГЕЙТОМ ПИЛОТА Ф2.5.** (Ядро-действие критик подтвердил на 4 основаниях: D1-компаундинг без пост-чека 06-реестр:60 · неизмеренная нужность на zh/ja→ru 06:120 · арх-коллизия mid-run-append `memory.go:157-159`/D30.8 · неподписанная «опция Б» COGS D30.4.) + +> ⚠ **Две пере-натяжки вправлены критиком:** +> 1. **«Детерминированное дата-состояние свободно от D1» — БЫЛО ЛОЖНО, СНЯТО.** Любое АВТОНОМНОЕ заполнение relationship/status-строк = LLM-экстракция (как сегодня dst); а арк/reveal-факты — source-anchored reveal БЕЗ фаерящего ключа (`mempostcheck.go:199-206` объявляет класс вне скоупа by construction). ⇒ дата-строка **РЕ-ИМПОРТИРУЕТ D1-компаундинг в структурной одежде, ЕСЛИ пилот не спроектирует source-anchored ВЕРИФИКАТОР** (целевой индекс пост-reveal-рендеров). «Детерминированное» = ФОРМАТ-фолдимо, НЕ популяция-верифицировано. +> 2. **«Резюме ортогонально» → «ратифицированная „вторая половина банка" (D39.66 стр.80, 06-реестр §D, D30.8), но НЕИЗМЕРЕННАЯ на этом стеке — отложена в пилот, НЕ ортогональна».** + +**Первый деливерабл пилота Ф2.5:** «допускает ли автономная нарратив-состояние-строка детерминированный верификатор, или это D1 со схемой?» — если верификатора нет, дата-строка НЕ безопаснее прозы, которую заменяет. Провабельно НЕ покрытые KWIC+окнами классы (что резюме БЫ несло): source-anchored reveal (`mempostcheck.go:199`) + арк-колбэки без ключа (sticky сбрасывается на границе главы `wave.go:85`) — реальны для длинного нарратива, но НЕ измеренный дефект текущей арки. Решение — владельца (§9-Q3). + +--- + +## §6. Дыра эмиссии 蛊 (строка 18) — ПОЛНАЯ ФОРМА · вправлена критикой (WEAKENED) + +### (а) Посылка +D39.58/строка 18: голова `蛊` не приходит НИ ОДНИМ каналом автономно. Грунтовано (шире, чем черновик): `emissionEligible` (`miner_emit.go:302-313`) бьёт (i) тип-гейтом (`蛊` бес-типовый V-A) (ii) длиной (runeLen<2). **Композиты `蛊师/月光蛊` ТОЖЕ вне эмитируемой дельты** — `formantType(蛊)="term"` (`miner_patterns.go:65`), тип-гейт `term` не пускает; живут в кандидатном СЕТЕ (`mr.ranked`; парити `蛊`@1/`蛊师`@2 `miner_parity_test.go:113`), до роли — через БАНКНОТУ. Доменная морфема `蛊` УЖЕ детектируется `detectFormants`/`charOverRep` (`miner_patterns.go:88-148`, `miner.go:46` `FormantMinPartners:3`/`FormantMinOverRep:15`) БЕЗ литерала ⇒ обобщается. «freq 62» — цифра COLDRUN, не грунтована кодом. + +### (б) Нужность — расщепить +- Центральный термин книги (в заглавии/брифе) — сид-концерн; майнер для ХВОСТА. Автономная нужность одиночной головы — СРЕДНЯЯ (концессия). +- Курс D39.67 = не полагаться на ручной труд. + +### (в) Форма — вправлено критиком: **seed-половину СТРОИТЬ; эмиссию-канал ДЕМОТИРОВАТЬ до полигон-замера + после #10** + +> ⚠ **Критик (WEAKENED):** единственное качество-обоснование канала — «пин головы → согласованные композиты» — НЕ доставлено построенным механизмом и НЕИЗМЕРЕНО (каждый брат-механизм §1/§2 заработал стройку живой пробой; §6 — нет). Ре-скоуп: +- **СТРОИТЬ сейчас — только честный дефолт-сид** (uncontested): определяющий термин книги сидится (`allow_short:true`, D39.50, легитимная курация; чек-лист сида). Покрывает единственный терм, что важен. +- **ДЕМОТИРОВАТЬ эмиссию-канал до ИЗМЕР-ГЕЙТА:** $0/дешёвый полигон-замер «меняет ли эмиссия голой головы РЕНДЕР композитов (蛊师/月光蛊/本命蛊) против не-эмиссии?». Строить ТОЛЬКО если замер покажет эффект — и **ПОСЛЕ лендинга #10** (одно-символьный пост-чек), НЕ параллельно: иначе `allow_short` одно-символьный ключ фаерит в КАЖДОМ композите (`memory.go:302` `转`-в-`转身`), а #10 это чинит. +- **Перед любой стройкой резолвить:** (i) `≥K-композитов`-гейт СЕЙЧАС само-провальный (term-формант-композиты эмиссия-блокированы `miner_emit.go:303` → K=0 для `蛊`) ИЛИ избыточен с `detectFormants.minPartners` (`miner.go:46`) — переформулировать; (ii) KWIC-дизамбигуация одиночной головы (`kwicFor` `terminology.go:285` матчит `蛊` ВНУТРИ каждого композита → грязный контекст-мешок для консолидации). + +**Диспозиция §9-Q7:** заказать ли полигон-замер эффекта эмиссии головы? + +### Снапшот-оси +Эмиссия — майнер-версия (не двигает `memory_version` до подписи). Приёмный `allow_short` фолдится (`memory.go:420`) ⇒ `--resnapshot`. Канал за флагом — парити байт-идентична off. + +### Тесты приёмки (если построится после замера+#10) +- Синтетика `{XY,ZX,WX}` (общая одиночная морфема, freq≥порог, ≥K композита) ⇒ эмит; редкая ⇒ нет. Майнер-парити: корпус без общих голов ⇒ байт-идентично. + +--- + +## §7. Кандидаты — вердикт-тройка 1–2 строки + +- **36г `{{banknote_format}}` (Р5):** УЖЕ — парсер в ОДНОМ файле (`pipeline/banknote.go:33/74/44/80`), проза в ОДНОМ промте (`translator-banknote.md`; НЕ «каждый»). **НИЗКО** (гигиена, линты держат `config/prompt_lint_test.go`). **Форма:** плейсхолдер `{{banknote_format}}` (закрытый набор `render.go:180` +1) ТОЛЬКО в форме, ГЕНЕРИРУЕМОЙ из констант парсера (иначе перенос дубля; `bankTypeOK` map → сортировать); worked-example в промте. Оппортунистически, не standalone. +- **STITCH-ретирование:** research/15 (гасить инъекцию после N чанков). **НИЗКО** — счётчика нет (`priorityRank` `memory.go:647-661`); ретируемые термы ВЫСОКОточны (не мусор A1/A2). **Форма:** закрыть до давления на МАСШТАБЕ; **реопен-триггер = бюджет ИЛИ измеренный эффект размера-инъекции на качество** (A6-lost-in-middle, критик — маргинальное качество-измерение, не только COGS). Дрейф-риск пост-чек-ловится. +- **Ranked-set:** инъективность dst warning-only ПО ДИЗАЙНУ (`memory.go:1104`). **КАЧЕСТВО закрывает §1** (упорядоченная серия head-aware). Отдельный тип+hard — низко; hard-режим **§9-Q4**. +- **tmctl «замена термина с главы N»:** механизм ПОСТРОЕН (`repin.go`+`rebill.go`, D39.42 п.5; команд `translate|report|status|export|redrive|seed-lint` `main.go:72-87`, замены нет). **РЕАЛЬНА** (операторский поток). **Форма:** тонкая CLI над построенным; пак операторского протокола (строка 49). +- **feed_cap:** гейт режет лексикографический хвост (`terminologist.go:261-266`). **МАСШТАБ-гейтед.** **Форма:** свёрнут в §1 (примитив B value-порядок) — строить с §1. +- **Эмбеддинг A7/D2:** **закрыть по НУЖНОСТИ, не по детерминизму** (критик вправил категор-ошибку). Две формы: (1) hot-path ретривер на промахах — ломает `request_hash`-фолд, верно запрещён (Р3, `migrate.go:248`); (2) РЕГИСТРОВАЯ A7/D2 (06-реестр:32/61/105) — BATCH-TIME, ВНЕ hot-path, low-trust генератор кандидатов, выход human/judge-промоутится во frozen-банк — арх-ИДЕНТИЧНА LLM-терминологу, что уже населяет банк, фолдится в `memory_version` (`snapshot.go:179-190`); НЕ детерминизм-блокирована. **Закрыть на ПРЕМИСЕ:** exact-match recall на автономном холодном НЕ измерен недостаточным. **Реопен = измеренный recall-разрыв на МАСШТАБЕ**, лендинг как batch-time лейн (популяция банка), НИКОГДА hot-path. +- **First-occurrence-тест:** ИСПОЛНЕН — §7.7. + +### §7.7. First-occurrence-тест (санкционированный $0, ИСПОЛНЕН) +Код: `eval/bank_autonomy/m_firstocc.py` (единицы ДО агрегата; ничьи/сочинённые/катастрофы экранированы). Данные (author≠reviewer): первое-вхождение-в-БАНКНОТУ = `retrieval_state.banknote_detail`; частотный-топ+ФИНАЛ = `parse_bankstop`; джойн EXACT; покрытие 67/75. + +**Результат — гипотеза первенства ОПРОВЕРГНУТА как механизм; роль-консолидатор ПОДТВЕРЖДЁН ПОЗИТИВНО.** Разделяющая подвыборка (первое ≠ частота, N=23 не-сочинённых): финал==первое **7**, финал==частота **7**, финал==иной черновик **9**, +6 сочинено. ⇒ роль НЕ первовхожденческий и НЕ частотный выбиратель — переопределяет ОБА контекстом (позитивно подтверждает вывод-по-исключению D39.65 §2). **Следствие §1:** серийную согласованность нельзя добыть порядком/весами — только со-батчем. **Следствие §2:** роль АВТОНОМНО сочиняет пиньинь-транслит для корректно-типированных `term`-реалий (§9-Q5). + +**Лимиты:** первое = в БАНКНОТЕ, не в исходнике; N мал, одна книга/модель/холодный; «эффективный ≠ верный»; 8/75 недостижимы (superseded, полный фолд дороже но $0). Не манифактурю сходимость — тест ОПРОВЕРГ гипотезу. + +--- + +## §8. Самопроверка (author≠reviewer) — ТРИ рубежа + +1. **Грунтинг (6 агентов):** утверждения о коде грунтованы `file:line`, перепроверены грепом/чтением исходника. Вправлены 3 моих черновых пере-натяжки (композиты `蛊师/月光蛊` вне дельты §6; банкнота-проза в ОДНОМ промте §7; neuter-ось `RenderFormatVersion` §3). +2. **Анкор-верификация (отдельный агент):** ВСЕ 8 tech-debt-якорей CONFIRMED против живого кода, 0 REFUTED, номера точны. +3. **Адверсариальная ДИЗАЙН-критика (5 агентов, дефолт «решение неверно»):** **2 BROKEN + 3 WEAKENED — ВСЕ вправлены:** + - **§1 серия-дефиниция BROKEN** → head-aware (я ПЕРЕ-ВЕРИФИЦИРОВАЛ на `BANK-FULL.tsv` сам: наивное правило = 19 кластеров вкл. блоб с §2-harm + протагонист; head-aware = 4 годных серии). Реконсиляция §1↔§2. + - **§2 инлайн-тип BROKEN** → классификатор ПЕРВИЧНЫМ (проверенная 6/6 форма, DO-роль ДО RenderBatch); $0-экран честно пере-скоупнут (не harm-пояс — провабельно пропускает юаньши/Юаньхай). + - **§5 WEAKENED** → «дата-состояние свободно от D1» снято (source-anchored reveal без верификатора); «ортогонально» → «ратифицированная вторая половина, неизмеренная». + - **§6 WEAKENED** → seed-половина строится, эмиссия-канал → измер-гейт + после #10. + - **§7 эмбеддинг WEAKENED** → закрыт по нужности (не детерминизму — категор-ошибка); STITCH-реопен += качество-триггер. +4. **First-occurrence-тест ИСПОЛНЕН** ($0). Пост-хок верификация оркестратора при лендинге — второй рубеж, не замена. + +--- + +## §9. ВОПРОСЫ ВЛАДЕЛЬЦУ (только реально его — вкус/деньги/продукт) + +- **Q1 (§1, деньги/риск):** строить ли автономный канон-пин родового слова МЕЖДУ прогонами (софт-вес авто-соседа)? Напряжение с D39.65 «веса на холодном не трогать». Реком: НЕ сейчас; вернуться на МАСШТАБЕ. +- **Q2 (§4, деньги/продукт):** заказать ли полигон-замер нужности рецензента-другого-семейства (36б)? Реком: да, ДО стройки; бэкенд-стройку 36б закрыть. +- **Q3 (§5, продукт/деньги):** подтвердить **закрыть проза-суммарайзер; строку 80 гейтом пилота Ф2.5 (первый деливерабл — «допускает ли нарратив-состояние-строка детерминированный верификатор?»)**? Резюме в арку качества банка не входит (0 измеренных дефектов, «опция Б» неподписана D30.4). +- **Q4 (§7 ranked-set):** hard-режим инъективности (сегодня warning-only ПО ДИЗАЙНУ)? Реком: нет; порядок закрыт §1. +- **Q5 (§2/§7.7, вкус):** желателен ли АВТОНОМНЫЙ пиньинь-транслит-регистр реалий, что роль сочиняет сама (юаньци/чжэньюань/цзин-ци-шэнь — вне §2), или «слишком китаизированно» → промпт-инструкция роли предпочитать смысл? Вкус/промпт, не тип. +- **Q6 (§3, продукт):** «сокращённая форма» (27) = морфоклип (`decl.forms[]` хватает, $0) или прозвище/диминутив (отдельное поле)? + компат: отбраковывать ли уже-сохранённые `gender:neuter`-сиды новым seed-lint? +- **Q7 (§6, деньги):** заказать ли $0/дешёвый полигон-замер «меняет ли эмиссия голой головы 蛊 рендер её композитов»? От него — строить ли эмиссию-канал (после #10) или закрыть (seed-половина достаточна). + +--- + +## §10. Сдвигаемые снапшот-оси (сводно) +- **Не двигают `memory_version` (только `terminologyVersion`/майнер-версия, лог):** §1 серия+value-порядок, §2 классификатор+$0-экран, §6 эмиссия головы (если построится), §7 feed_cap-порядок. +- **Двигают `--resnapshot` (точечный repin):** §2 смена ТИПА строки (`memory_version`); §3 neuter (`RenderFormatVersion` — НЕ `memory_version`), краткая форма в `decl.forms` (`memory_version`), `allow_short` головы §6 (`memory_version`); §3 стеммер — в хеш ТОЛЬКО при hard-gate. +- **Приёмка этапа 2 (по промту):** «пара, которой нет в репо, без правки Go» · голден Ш-1 (пере-захват раз, маскированный дифф, вскрытые единицы, вердикт-двиги ВЕРНЫ) · майнер-парити EXACT · `-race` весь пакет · мутационные тесты на новые швы. + +--- + +## §11. Итог (в §Бэкенд PROGRESS.md — 3–5 строк) +Дизайн-пак качества банка (этап 1, $0, кода не писал кроме санкц. `m_firstocc.py`): 6 пунктов → **3 переиспользуемых примитива** (A HEAD-AWARE кластер-по-морфеме: §1 серия+§6 голова · B ре-порядок батча: §1+feed_cap · C стеммер-цели: §3 Decl+omission-бэкстоп чекеров) + дата-правки (тип КЛАССИФИКАТОРОМ-ПЕРВИЧНЫМ; род neuter enum+seed-lint, ось `RenderFormatVersion`). **Пройдено 3 рубежа самопроверки (грунтинг+анкор-верификация+5-агентная дизайн-критика): §1 серия-дефиниция и §2 инлайн-тип были BROKEN — переработаны (§1 head-aware, я пере-верифицировал на BANK-FULL сам; §2 классификатор первичным); §5/§6/§7 WEAKENED-правки вправлены.** §4 (36б) и §7-эмбеддинг ЗАКРЫТЫ по нужности; §5 (резюме) и §6-эмиссия — гейтнуты замером. First-occurrence-тест ($0): гипотеза первенства ОПРОВЕРГНУТА, роль-консолидатор подтверждён позитивно. 7 вопросов владельцу (§9). СТОП на ратификацию; этап 2 — по релею после чекеров. diff --git a/eval/bank_autonomy/README.md b/eval/bank_autonomy/README.md index 461be991..d36fbea0 100644 --- a/eval/bank_autonomy/README.md +++ b/eval/bank_autonomy/README.md @@ -14,6 +14,7 @@ - `m21_consistency.py`/`m21b.py` — строка 21: консистентность терминолога по 3 вложенным проходам (n≥2=127, нестаб 43.3%). - `m22_consolidation.py`/`m22b.py` — строка 22: веса §C2-3 (n≥2=75; term-топ==частота 43/43; final≠топ 50/75). - `m36_typeaudit.py`/`m36b_grade.py` — аудит типов 36а (12–22% мис-тип; branch-harm 元石/元海; decl 142/142 null). +- `m_firstocc.py` — first-occurrence-тест KWIC-консолидатора (дизайн-пак банка §7.7, 01.08): первое-вхождение-в-БАНКНОТУ (`retrieval_state.banknote_detail`, порядок `(chapter,chunk_idx)`) vs частотный-топ vs ФИНАЛ. Единицы печатаются ДО агрегата (норма «вывод на агрегате до вскрытия единиц»). Итог: гипотеза первенства ОПРОВЕРГНУТА (финал==первое 7 == финал==частота 7, большинство — ни то ни другое) ⇒ роль = контекстный ре-решатель, не выбиратель по порядку/частоте. ## Платные пробы (term_probe.py, venv eval/.venv) Реконструирует вход терминолога байт-близко к `terminology.go:RenderBatch`, бьёт в DeepSeek напрямую. diff --git a/eval/bank_autonomy/m_firstocc.py b/eval/bank_autonomy/m_firstocc.py new file mode 100644 index 00000000..593a92a3 --- /dev/null +++ b/eval/bank_autonomy/m_firstocc.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""FIRST-OCCURRENCE test of the KWIC consolidator (backlog 73 candidate). $0 — no model calls. + +Positive test of the hypothesis "the terminologist is a KWIC/first-occurrence consolidator, not a +frequency-majority picker" (vs the conclusion-BY-EXCLUSION of D39.65 §2). Reads only durable coldrun-a +artifacts read-only (D39.58). + +DATA (grounded by author!=reviewer, 01.08): + - first-occurrence-in-BANKNOTE per source term: retrieval_state.banknote_detail (JSON {k,s,d,t}), + ordered by (chapter, chunk_idx), first-wins. NOTE the honest scope: this is first-seen-in-BANKNOTE + (when the drafter first DECLARED the term), NOT first-appearance-in-source-text. + - freq-majority top + FINAL terminologist dst: parse_bankstop() (drafts: are score/freq-ordered; + r['dst'] is the terminologist FINAL, == terminologist checkpoint output). + - join is EXACT on the normalized source key (banknote k is already NormalizeSourceKey'd; bank-stop + src is raw Han → for Han these coincide; we also try a light-normalized fallback and REPORT coverage). + +DISCIPLINE (eval-aggregation lesson + prompt): UNITS ARE PRINTED FIRST. The discriminating subpopulation +(first-occ != freq-top) is isolated and read unit-by-unit BEFORE any aggregate; ties (first==freq), +coined FINALs (== neither draft), and catastrophes are screened against the winner too. No aggregate is +emitted before the units. +""" +import json, sys +from parse_common import con, parse_bankstop + +def freq_top(variants): # inlined (do not import m22: its module-level analysis would re-run) + return sorted(variants, key=lambda x: (-x[1], x[0]))[0][0] + +def norm(s): + return " ".join(s.strip().lower().replace("«", "").replace("»", "").replace('"', "").split()) + +# --- first-occurrence-in-banknote per normalized source key --- +c = con() +firstocc = {} # k -> (chapter, chunk_idx, dst) +nchunks = 0 +for chapter, chunk_idx, detail in c.execute( + "SELECT chapter, chunk_idx, banknote_detail FROM retrieval_state " + "WHERE banknote_detail != '' ORDER BY chapter, chunk_idx"): + nchunks += 1 + try: + arr = json.loads(detail) + except Exception: + continue + for e in arr: + k = e.get("k", "") + if k and k not in firstocc: + firstocc[k] = (chapter, chunk_idx, e.get("d", "")) +c.close() +print(f"# banknote chunks with detail = {nchunks}; distinct first-occ keys = {len(firstocc)}") + +bs = parse_bankstop() +# n>=2 population: terms with >=2 DISTINCT draft variants (the only ones where a consolidation CHOICE exists) +n2 = {s: r for s, r in bs.items() if len({d for d, _ in r["drafts"]}) >= 2} + +# join bank-stop term -> its first-occ dst; try raw then light-normalized key +def firstocc_for(src): + if src in firstocc: + return firstocc[src] + nk = norm(src) + for k, v in firstocc.items(): + if norm(k) == nk: + return v + return None + +reached = {s: r for s, r in n2.items() if firstocc_for(s) is not None} +print(f"# n>=2 (>=2 distinct drafts) = {len(n2)}; reached via banknote first-occ = {len(reached)}\n") + +# ============ UNITS FIRST — the discriminating subpopulation (first-occ != freq-top) ============ +rows = [] +for s, r in reached.items(): + ch, ci, first_d = firstocc_for(s) + drafts = r["drafts"] + ft = freq_top(drafts) + final = r["dst"] + draft_set = {norm(d) for d, _ in drafts} + label_first = norm(final) == norm(first_d) + label_freq = norm(final) == norm(ft) + coined = norm(final) not in draft_set + rows.append(dict(src=s, typ=r.get("type", ""), ch=ch, ci=ci, first_d=first_d, ft=ft, + final=final, drafts=[d for d, _ in drafts], + first_eq_freq=(norm(first_d) == norm(ft)), + label_first=label_first, label_freq=label_freq, coined=coined)) + +discriminating = [x for x in rows if not x["first_eq_freq"]] +print(f"===== DISCRIMINATING SUBPOPULATION: first-occ != freq-top (N={len(discriminating)} of {len(rows)} reached) =====") +print("(units read individually BEFORE any aggregate)\n") +for x in sorted(discriminating, key=lambda z: (z["ch"], z["ci"])): + tag = "FINAL=first" if x["label_first"] and not x["label_freq"] else \ + "FINAL=freq" if x["label_freq"] and not x["label_first"] else \ + "FINAL=both" if x["label_first"] and x["label_freq"] else \ + "FINAL=COINED" if x["coined"] else "FINAL=other-draft" + print(f" {x['src']} [{x['typ']}] ch{x['ch']}.{x['ci']} first='{x['first_d']}' freq='{x['ft']}' " + f"FINAL='{x['final']}' -> {tag}") + print(f" drafts={x['drafts']}") + +# ties (first==freq: NON-discriminating) and coined — screened separately +ties = [x for x in rows if x["first_eq_freq"]] +coined_all = [x for x in rows if x["coined"]] +print(f"\n===== NON-DISCRIMINATING (first-occ == freq-top): N={len(ties)} (cannot separate the two hypotheses) =====") +print(f"===== COINED FINALs (== neither any draft variant): N={len(coined_all)} (role invented, screened separately) =====") +for x in coined_all[:20]: + print(f" {x['src']} [{x['typ']}] FINAL='{x['final']}' drafts={x['drafts']} first='{x['first_d']}'") + +# ============ AGGREGATE (only over the DISCRIMINATING, non-coined units) ============ +disc_nc = [x for x in discriminating if not x["coined"]] +first_only = sum(1 for x in disc_nc if x["label_first"] and not x["label_freq"]) +freq_only = sum(1 for x in disc_nc if x["label_freq"] and not x["label_first"]) +other_draft = sum(1 for x in disc_nc if not x["label_first"] and not x["label_freq"]) +disc_coined = sum(1 for x in discriminating if x["coined"]) +print(f"\n===== AGGREGATE over DISCRIMINATING non-coined units (N={len(disc_nc)}) =====") +print(f" FINAL == first-occ ONLY : {first_only}") +print(f" FINAL == freq-top ONLY : {freq_only}") +print(f" FINAL == some other draft (neither first nor freq): {other_draft}") +print(f" (+ {disc_coined} discriminating units where FINAL was COINED — excluded from the ratio)") +print("\nNOTE (honest scope): 'first-occ' = first-seen-in-BANKNOTE, not first-in-source-text; 8/75 multi-") +print("variant terms unreachable via retrieval_state (superseded generations) are OUT of this first cut.")