From 1b19345ec4e9a7598021a4a964e176348c850e93 Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sun, 26 Jul 2026 17:51:38 +0300 Subject: [PATCH] Land pack twenty delta: genre glossary machinery unwound per D39.47, canon anchors and narrow transcription rule kept, targeted suite and golden green --- .../langpacks/zh-ru/genre-glossary.txt | 36 ---- backend/internal/lang/langpack.go | 94 +-------- backend/internal/lang/langpack_test.go | 147 +++----------- .../internal/pipeline/miningstop_join_test.go | 21 +- backend/internal/pipeline/terminologist.go | 33 +-- backend/internal/terminology/terminology.go | 91 +++------ .../internal/terminology/terminology_test.go | 30 ++- backend/prompts/zh-ru/terminologist.md | 16 +- .../reports/PACK20_DELTA_2026-07-26.md | 192 ++++++++++++++++++ 9 files changed, 286 insertions(+), 374 deletions(-) delete mode 100644 backend/configs/langpacks/zh-ru/genre-glossary.txt create mode 100644 docs/archive/reports/PACK20_DELTA_2026-07-26.md diff --git a/backend/configs/langpacks/zh-ru/genre-glossary.txt b/backend/configs/langpacks/zh-ru/genre-glossary.txt deleted file mode 100644 index d178ab9c..00000000 --- a/backend/configs/langpacks/zh-ru/genre-glossary.txt +++ /dev/null @@ -1,36 +0,0 @@ -# GENRE reference glossary for zh→ru (pack-20 / D39.42 п.1). The renderings the RU market has settled on -# for this pair's cultivation-fiction genre, read by the TERMINOLOGIST as an anchor so an unsigned book -# does not re-invent a term the whole industry already renders one way (owner, 26.07: «не надо тащить -# чужой фан-канон, но надо тащить общепринятое»). -# -# WHAT BELONGS HERE: pair/genre conventions — words any translator of this genre meets in any book. -# WHAT DOES NOT: a BOOK's canon (its characters, sects, techniques, its private clan names). Those live -# with the book (its seed / CANON-NOTES.md) and never in the shared pair layer — a book term here is the -# leak the generality guardrail names. -# -# FORMAT: `srcdst[genre]` per line. The third column is an OPTIONAL free label matched against -# the book's `genre` field (case-folded); an empty label applies to every book of the pair. The rows below -# are deliberately UNLABELLED: an anchor for a term the book never uses is inert, whereas a label that -# fails to match the book's own wording («ранобэ» vs «сянься») would silently disable the whole file. -# -# CURATOR: the owner. This list is deliberately MINIMAL — only renderings that are uncontested in the RU -# market. Extending it is an editorial decision, not a session's: a wrong "industry standard" here is a -# canon imposed on every book of the pair at once. -# -# STATUS 26.07: UNSIGNED DRAFT. The rows below were authored by a backend session as a mechanism fixture, -# not by the curator. Polygon package seven found the market evidence for 修炼 / 修士 / 筑基 CONTESTED -# (register variants, V0–V3, possibly a register column), so those three are HELD — commented out below -# rather than deleted, because an anchor is presented to the model as the industry default and a contested -# default is worse than no default at all. Un-holding is a one-line data edit and costs nothing: this file -# is loaded as data, is not folded into any snapshot, and no test asserts its contents. -# -# HELD pending the owner's signature (polygon pkg-7 evidence, V0–V3): -# 修炼 культивация -# 修士 культиватор -# 筑基 закладка основания -修真 культивация -修行 культивация -丹田 даньтянь -经脉 меридианы -灵气 духовная энергия -金丹 золотое ядро diff --git a/backend/internal/lang/langpack.go b/backend/internal/lang/langpack.go index 115030d0..98dd1fbe 100644 --- a/backend/internal/lang/langpack.go +++ b/backend/internal/lang/langpack.go @@ -70,15 +70,6 @@ type Pack struct { // LOOKUP TABLES move out of the pipeline; the regex DETECTION patterns stay as the checker algorithm). DCCheckers *DCCheckerData - // GenreGlossary is the OPTIONAL pair/genre reference glossary (configs/langpacks//genre-glossary.txt, - // D39.42 п.1): the renderings the INDUSTRY has settled on for this pair's genre (修炼 → «культивация», not - // «совершенствование»). It is PAIR/GENRE data, never book canon — a book's private names live in its own - // CANON-NOTES/seed and never in the shared pair layer. The terminologist reads it as an anchor so an - // unsigned book does not re-invent a term the whole market already renders one way. nil when the pair - // ships no file. Its bytes are NOT part of Version() — see Load: this is the one optional pair file that - // shapes no snapshot-folded stage, so curating it must not re-bill books. - GenreGlossary []GenreTerm - // Heading is the OPTIONAL chapter-heading rule (configs/langpacks//heading.txt). nil when the pair // carries no heading.txt — the chapter-title feature is then inert (the chunker keeps the source header // as-is), so a pair that does not opt in is never re-billed for it. It is DATA only: the detect/strip/ @@ -131,16 +122,6 @@ type DCCheckerData struct { Messages map[string]string } -// GenreTerm is one row of the pair's genre reference glossary. Genre is an OPTIONAL free label matched -// against the book's `genre` field (case-folded, trimmed); an empty Genre applies to every book of the -// pair. Keeping the label in the DATA is what lets a second genre (or a second pair) ship its own -// conventions without a Go edit — the engine only ever compares two strings it was handed. -type GenreTerm struct { - Src string - Dst string - Genre string -} - // HeadingRule is the per-pair data for the chapter-title policy: instead of letting the model render a // chapter heading (which drifted to «Раздел 2» / «Первая глава» / an orphaned « :» across models), the // chunker detects a source header (Marker + a numeral + a Unit rune), strips it from the model input, and @@ -242,25 +223,11 @@ func Load(root, sourceLang, targetLang string) (*Pack, error) { p.DCCheckers = dc } - // Optional per-pair GENRE glossary (pack-20 / D39.42 п.1). Same optional contract as heading.txt: ABSENT - // → nil; CORRUPT → loud. DELIBERATELY NOT FOLDED into the content hash, unlike every other optional file - // here. The rule the fold encodes is "this data shapes the WIRE or the VERDICT of a snapshot-folded - // stage": the miner tables, the heading rule and the DC checkers all do. This one does not — it is read - // by exactly one consumer, the TERMINOLOGIST's request, and that call class is itself deliberately - // outside the snapshot (config.TerminologyGate). Folding it would make an EDITORIAL edit — the curator - // signing one industry rendering — move every book's snapshot, and a langpack move is not a bank-only - // move, so not one chunk of those books could re-pin at $0: a one-line data edit would re-buy whole - // waves. The edit is still not free where it should not be: the terminologist's own request hash covers - // the rendered anchor, so its batches are re-bought and nothing else is. - if gb, ok, gerr := readOptional(root, pair, "genre-glossary.txt"); gerr != nil { - return nil, fmt.Errorf("langpack %q genre-glossary.txt: %w", pair, gerr) - } else if ok { - gg, perr := parseGenreGlossary(gb) - if perr != nil { - return nil, fmt.Errorf("langpack %q genre-glossary.txt: %w", pair, perr) - } - p.GenreGlossary = gg - } + // A per-pair GENRE glossary was read here (pack-20 / D39.42 п.1) and was removed by D39.47: a pair-wide + // file of "the industry rendering" prescribes ONE register to every book of the pair, and the choice + // between «культивация» and «совершенствование» belongs to the owner's signature on a book's bank — + // legitimately different from book to book. Nothing replaced it; the terminologist's only anchor is that + // signed bank. Recorded here because the absence is a decision, not an omission. if err := p.validate(); err != nil { return nil, fmt.Errorf("langpack %q: %w", pair, err) @@ -587,57 +554,6 @@ func parseHeading(b []byte) (*HeadingRule, error) { return hr, nil } -// parseGenreGlossary reads genre-glossary.txt into the ordered GenreTerm rows. Format per non-comment -// line: `srcdst[genre]`. AUTHORED ORDER is preserved (the anchor block a model reads is a list, -// and its order is the curator's). Fail-loud on a malformed row (fewer than two fields, an empty src or -// dst): a silently-dropped convention is worse than a refused pack — the whole point of the file is that -// the engine does not re-invent a rendering the market already agreed on. -func parseGenreGlossary(b []byte) ([]GenreTerm, error) { - var out []GenreTerm - seen := map[string]int{} // src|genre → line, so a duplicated key cannot silently shadow itself - for i, raw := range strings.Split(string(b), "\n") { - t := strings.TrimRight(raw, "\r") - if strings.TrimSpace(t) == "" || strings.HasPrefix(strings.TrimSpace(t), "#") { - continue - } - f := strings.Split(t, "\t") - if len(f) < 2 { - return nil, fmt.Errorf("line %d: want `srcdst[genre]` (%q)", i+1, t) - } - g := GenreTerm{Src: strings.TrimSpace(f[0]), Dst: strings.TrimSpace(f[1])} - if len(f) >= 3 { - g.Genre = strings.TrimSpace(f[2]) - } - if g.Src == "" || g.Dst == "" { - return nil, fmt.Errorf("line %d: src and dst must both be non-empty (%q)", i+1, t) - } - key := g.Src + "\x00" + strings.ToLower(g.Genre) - if prev, dup := seen[key]; dup { - return nil, fmt.Errorf("line %d: duplicate src %q for genre %q (already on line %d) — two conventions for one term is a curator decision, not a silent last-wins", i+1, g.Src, g.Genre, prev) - } - seen[key] = i + 1 - out = append(out, g) - } - return out, nil -} - -// GenreGlossaryFor returns the pack's genre-glossary rows that apply to a book of the given genre: rows -// with no genre label (pair-wide conventions) plus rows whose label matches, case-folded and trimmed. -// Authored order is preserved. A nil pack or an empty glossary yields nil — the caller renders no anchor. -func (p *Pack) GenreGlossaryFor(genre string) []GenreTerm { - if p == nil || len(p.GenreGlossary) == 0 { - return nil - } - want := strings.ToLower(strings.TrimSpace(genre)) - var out []GenreTerm - for _, g := range p.GenreGlossary { - if g.Genre == "" || strings.ToLower(strings.TrimSpace(g.Genre)) == want { - out = append(out, g) - } - } - return out -} - // runeSet reads a rune SET: every non-whitespace rune of every non-comment line is a member (order-free). func runeSet(b []byte) map[rune]bool { m := map[rune]bool{} diff --git a/backend/internal/lang/langpack_test.go b/backend/internal/lang/langpack_test.go index e0dbf05d..a335c718 100644 --- a/backend/internal/lang/langpack_test.go +++ b/backend/internal/lang/langpack_test.go @@ -3,7 +3,6 @@ package lang import ( "os" "path/filepath" - "reflect" "strings" "testing" ) @@ -389,146 +388,46 @@ func TestChapterMarkerIsData(t *testing.T) { } } -// --- genre glossary (pack-20 / D39.42 п.1) ------------------------------------------------------- - -// TestGenreGlossaryLoadsAndScopesByGenre pins the pair/genre reference data: it reaches the pack, its -// rows keep their authored order, and the optional genre label scopes them. Unlabelled rows are the -// pair-wide conventions every book of the pair reads. -func TestGenreGlossaryLoadsAndScopesByGenre(t *testing.T) { - p, err := Load("../../configs/langpacks", "zh", "ru") - if err != nil { - t.Fatalf("load: %v", err) - } - // The MECHANISM is pinned, never the CONTENT. The file is an unsigned draft curated by the owner, and - // polygon package seven has rows of it out for a signature decision — a test naming a specific rendering - // would make editing one line cost a test edit, which is exactly the cementing the addendum of 26.07 - // forbids. So: the pack loads a glossary, every row is well-formed, and nothing is asserted about which - // words are in it. - if len(p.GenreGlossary) == 0 { - t.Fatal("the shipped zh-ru pack must carry a genre glossary (the terminologist's industry anchor)") - } - for _, g := range p.GenreGlossary { - if strings.TrimSpace(g.Src) == "" || strings.TrimSpace(g.Dst) == "" { - t.Fatalf("every shipped row must carry both sides: %#v", g) - } - } - // An unlabelled row applies to every genre, including one the file never mentions. - if len(p.GenreGlossaryFor("ранобэ")) != len(p.GenreGlossary) { - t.Fatalf("unlabelled rows must apply to any genre: %d of %d", len(p.GenreGlossaryFor("ранобэ")), len(p.GenreGlossary)) - } -} - -func TestGenreGlossaryParseAndScoping(t *testing.T) { - rows, err := parseGenreGlossary([]byte("# c\n甲\tальфа\n乙\tбета\tсянься\n\n丙\tгамма\tРоман\n")) - if err != nil { - t.Fatalf("parse: %v", err) - } - want := []GenreTerm{{Src: "甲", Dst: "альфа"}, {Src: "乙", Dst: "бета", Genre: "сянься"}, {Src: "丙", Dst: "гамма", Genre: "Роман"}} - if !reflect.DeepEqual(rows, want) { - t.Fatalf("parse = %#v, want %#v", rows, want) - } - p := &Pack{GenreGlossary: rows} - // Case-folded, trimmed label match; unlabelled rows always apply. - got := p.GenreGlossaryFor(" роман ") - if len(got) != 2 || got[0].Src != "甲" || got[1].Src != "丙" { - t.Fatalf("genre scoping = %#v", got) - } - if len(p.GenreGlossaryFor("")) != 1 { - t.Fatalf("a book with no genre reads only the pair-wide rows, got %#v", p.GenreGlossaryFor("")) - } - // A malformed row is a LOUD refusal, never a silently dropped convention. - for _, bad := range []string{"甲\n", "甲\t\n", "\tальфа\n", "甲\tальфа\n甲\tбета\n"} { - if _, err := parseGenreGlossary([]byte(bad)); err == nil { - t.Fatalf("malformed glossary %q must fail loud", bad) - } - } -} - -// TestGenreGlossaryAbsenceIsFree pins the optional contract: a pair WITHOUT the file loads fine and its -// pack version is unaffected by the feature existing — shipping the terminologist re-bills nobody. -func TestGenreGlossaryAbsenceIsFree(t *testing.T) { - root := t.TempDir() - mirrorPackWithout(t, "../../configs/langpacks", root, "zh-ru/genre-glossary.txt") - p, err := Load(root, "zh", "ru") - if err != nil { - t.Fatalf("a pair with no genre glossary must load: %v", err) - } - if p.GenreGlossary != nil { - t.Fatalf("absent file → nil glossary, got %#v", p.GenreGlossary) - } - if len(p.GenreGlossaryFor("любой")) != 0 { - t.Fatal("a pack with no glossary must yield no anchor") - } -} - -// mirrorPackWithout copies a langpack tree to dst, skipping one relative path. -func mirrorPackWithout(t *testing.T, src, dst, skip string) { - t.Helper() - err := filepath.Walk(src, func(path string, info os.FileInfo, err error) error { - if err != nil { - return err - } - rel, rerr := filepath.Rel(src, path) - if rerr != nil { - return rerr - } - if info.IsDir() { - return os.MkdirAll(filepath.Join(dst, rel), 0o755) - } - if filepath.ToSlash(rel) == skip { - return nil - } - b, rerr := os.ReadFile(path) - if rerr != nil { - return rerr - } - return os.WriteFile(filepath.Join(dst, rel), b, 0o644) - }) - if err != nil { - t.Fatal(err) - } -} - -// TestGenreGlossaryEditDoesNotMoveThePackVersion is the addendum's «правка строки не должна стоить -// пере-капчера», enforced where it actually costs money: the pack version is folded into the run snapshot, -// so if this file were hashed, the curator signing one industry rendering would re-bill every book of the -// pair — and a langpack move is not a bank-only move, so none of it could re-pin at $0. Runs over a COPY of -// the shipped pack, so it pins the real schema rather than a fixture that might drift from it. -func TestGenreGlossaryEditDoesNotMoveThePackVersion(t *testing.T) { +// TestPairDataEditMovesThePackVersion pins the FOLD, which is what makes a data edit a loud --resnapshot +// instead of a silent one: every authored pack file shapes a snapshot-folded stage, so editing any of them +// must move Version(). Runs over a COPY of the shipped pack, so it pins the real schema rather than a +// fixture that might drift from it. +// +// It was written the other way round for the genre glossary (the one optional file DELIBERATELY left out of +// the hash, so that curating an industry rendering would not re-bill every book of the pair). D39.47 removed +// that file and with it the exemption; what survives is the plain rule, and this test is the guard that no +// future optional file quietly acquires the same hole. +func TestPairDataEditMovesThePackVersion(t *testing.T) { root := t.TempDir() for _, dir := range []string{"zh", "zh-ru"} { copyPackDir(t, filepath.Join("../../configs/langpacks", dir), filepath.Join(root, dir)) } - gg := filepath.Join(root, "zh-ru", "genre-glossary.txt") - if _, err := os.Stat(gg); err != nil { - t.Skipf("the shipped pair ships no genre glossary: %v", err) - } p1, err := Load(root, "zh", "ru") if err != nil { t.Fatal(err) } - before := len(p1.GenreGlossary) - if err := os.WriteFile(gg, []byte("# edited by the curator\n甲乙丙\tальфа-бета\n"), 0o644); err != nil { + sur := filepath.Join(root, "zh", "surnames-single.txt") + b, err := os.ReadFile(sur) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(sur, append(b, []byte("\n\u4EC7\n")...), 0o644); err != nil { t.Fatal(err) } p2, err := Load(root, "zh", "ru") if err != nil { t.Fatal(err) } - if p1.Version() != p2.Version() { - t.Fatalf("an editorial glossary edit must not move the pack version (it would re-bill every book of the pair):\n %s\n %s", p1.Version(), p2.Version()) + if p1.Version() == p2.Version() { + t.Fatal("a miner-table edit MUST move the pack version (it shapes a folded stage)") } - if len(p2.GenreGlossary) != 1 || p2.GenreGlossary[0].Dst != "альфа-бета" { - t.Fatalf("…and the edit must still take effect for the terminologist (was %d rows): %+v", before, p2.GenreGlossary) - } - // A file that DOES shape a snapshot-folded stage must still move the version — the exemption is one file - // wide, not a hole in the fold. - sur := filepath.Join(root, "zh", "surnames-single.txt") - b, err := os.ReadFile(sur) + // And the pair half of the pack is folded too, not just the source half. + pal := filepath.Join(root, "zh-ru", "palladius.txt") + pb, err := os.ReadFile(pal) if err != nil { t.Fatal(err) } - if err := os.WriteFile(sur, append(b, []byte("\n仇\n")...), 0o644); err != nil { + if err := os.WriteFile(pal, append(pb, []byte("\n# curator note\n")...), 0o644); err != nil { t.Fatal(err) } p3, err := Load(root, "zh", "ru") @@ -536,7 +435,7 @@ func TestGenreGlossaryEditDoesNotMoveThePackVersion(t *testing.T) { t.Fatal(err) } if p3.Version() == p2.Version() { - t.Fatal("a miner-table edit MUST move the pack version (it shapes a folded stage)") + t.Fatal("a pair-table edit MUST move the pack version too") } } diff --git a/backend/internal/pipeline/miningstop_join_test.go b/backend/internal/pipeline/miningstop_join_test.go index 632ce1ca..ffd01d5d 100644 --- a/backend/internal/pipeline/miningstop_join_test.go +++ b/backend/internal/pipeline/miningstop_join_test.go @@ -427,20 +427,13 @@ func TestCanonAnchorPutsTheSignedBankInFrontOfTheTerminologist(t *testing.T) { if !strings.Contains(termBody, `цветочное море`) { t.Fatalf("the SIGNED rendering itself must be in front of the role:\n%s", termBody) } - // The genre convention rides the same message but ranks BELOW the canon, so a book's own signature - // cannot be outvoted by an industry default. - ci, gi := strings.Index(termBody, terminology.CanonMarker), strings.Index(termBody, terminology.GenreMarker) - // The genre block must BE on the wire — but which words are in it is the owner's unsigned draft, so the - // assertion is on the mechanism (the block is present and non-empty), never on a rendering. Cementing a - // row here would make an editorial edit cost a test edit (addendum, 26.07). - if gi < 0 { - t.Fatalf("the pair/genre convention set is an input of the role (D39.42 п.1) and must be on the wire:\n%s", termBody) - } - if genre := strings.TrimSpace(termBody[gi+len(terminology.GenreMarker):]); !strings.Contains(genre, `\t`) { - t.Fatalf("the genre block must carry at least one src→dst row:\n%s", termBody) - } - if ci > gi { - t.Fatalf("the canon block must precede the genre block:\n%s", termBody) + // D39.47: the signed bank is the ONLY anchor. A second block of pair/genre "industry" renderings rode + // this same message in pack-20 and was removed — prescribing one register to every book of the pair is + // exactly what the owner's signature on THIS book decides instead. The assertion is generic on purpose: + // any second anchor block, whatever it is called, fails here. (The fixture prompt above carries no + // ⟦TM-…⟧ token of its own, so every occurrence on the wire comes from the injected anchor.) + if n := strings.Count(termBody, "⟦TM-"); n != strings.Count(termBody, terminology.CanonMarker) || n == 0 { + t.Fatalf("the canon anchor must be the only block on the role's wire (found %d anchor markers):\n%s", n, termBody) } } diff --git a/backend/internal/pipeline/terminologist.go b/backend/internal/pipeline/terminologist.go index 907b2c9e..50e58f74 100644 --- a/backend/internal/pipeline/terminologist.go +++ b/backend/internal/pipeline/terminologist.go @@ -127,7 +127,7 @@ func (r *Runner) buildBankCandidates(mined []miner.Term, observed []terminology. _, kwicPer, kwicWidth := r.terminologyOpts() cands = terminology.AttachKWIC(cands, chunks, kwicPer, kwicWidth) - opts := terminology.ScoreOpts{Neighbours: r.approvedNeighbours(), GenreDst: r.genreGlossaryMap()} + opts := terminology.ScoreOpts{Neighbours: r.approvedNeighbours()} if r.pack != nil { pack := r.pack opts.Conformance = func(dst, typ string) float64 { @@ -160,20 +160,6 @@ func (r *Runner) approvedNeighbours() []terminology.Neighbour { return out } -// genreGlossaryMap is the pair/genre convention set for this book (src → industry rendering), or nil when -// the pair ships no glossary. -func (r *Runner) genreGlossaryMap() map[string]string { - rows := r.pack.GenreGlossaryFor(r.Book.Genre) - if len(rows) == 0 { - return nil - } - out := make(map[string]string, len(rows)) - for _, g := range rows { - out[text.NormalizeSourceKey(g.Src)] = g.Dst - } - return out -} - // runTerminologist calls the role over the candidate list and returns key → consolidated rendering. With // the gate off it is a no-op returning an empty map and a zero result, so every existing book takes a // byte-identical path and pays nothing. @@ -310,23 +296,18 @@ func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []te } // terminologyMessages renders ONE batch into the wire messages: the pair's authored role prompt (system), -// the two anchors as a code-assembled injection message, and the candidate block as the user turn. The +// the canon anchor as a code-assembled injection message, and the candidate block as the user turn. The // block goes through {{text}} — the closed placeholder set stays closed, exactly as the memory injection // did rather than growing a new placeholder. // -// The anchors are ordered law-then-default: the book's own SIGNED rows (CANON — what the owner already -// decided, which a consolidation may not contradict), then the pair's genre conventions (GENRE — the -// industry default for terms nobody signed). Both are DATA; every word explaining them lives in the pair's -// prompt, so a new pair needs no Go edit. +// The anchor carries the book's own SIGNED rows (CANON — what the owner already decided, which a +// consolidation may not contradict) and nothing else: the pair/genre block that shipped beside it was +// removed by D39.47, because a market-wide default is exactly the thing this project has no authority to +// assert. It is DATA; every word explaining it lives in the pair's prompt, so a new pair needs no Go edit. func (r *Runner) terminologyMessages(batch []terminology.Candidate, canon []terminology.Neighbour) ([]llm.Message, error) { - rows := r.pack.GenreGlossaryFor(r.Book.Genre) - genre := make([][2]string, 0, len(rows)) - for _, g := range rows { - genre = append(genre, [2]string{g.Src, g.Dst}) - } return MessagesWithInjection(r.terminologyTemplate, RenderVars{Book: r.Book, Text: terminology.RenderBatch(batch)}, - terminology.RenderAnchors(terminology.CanonFor(batch, canon, terminologyCanonCap), genre)) + terminology.RenderCanonAnchor(terminology.CanonFor(batch, canon, terminologyCanonCap))) } // terminologyCanonCap bounds the signed rows one batch carries. The anchor is a REMINDER of the law that diff --git a/backend/internal/terminology/terminology.go b/backend/internal/terminology/terminology.go index 2060dfe5..3473513d 100644 --- a/backend/internal/terminology/terminology.go +++ b/backend/internal/terminology/terminology.go @@ -10,8 +10,8 @@ // exactly what exists at the wave boundary and nowhere else. // // Everything here is PURE and deterministic: no clock, no randomness, no map-order iteration, no I/O, no -// network. The pair data (Palladius conformance, the genre glossary) arrives as values, so the package -// holds no pair-specific literal and works for a pair that is not in the repo yet. +// network. The pair data (Palladius conformance) arrives as values, so the package holds no pair-specific +// literal and works for a pair that is not in the repo yet. package terminology import ( @@ -353,10 +353,11 @@ type ScoreOpts struct { // PLACE, in [0,1]. nil → the factor is neutral (1.0) for every variant. Conformance func(dst, typ string) float64 // Neighbours are the already-approved bank rows the "agreement with signed siblings" factor consults. + // This is the ONLY consistency anchor of the formula (D39.47 removed the pair/genre one): a signed row + // is a FACT about this book, whereas a pair-wide glossary would have prescribed one register to every + // book of the pair — and the choice between «культивация» and «совершенствование» is legitimately the + // owner's, per book. Neighbours []Neighbour - // GenreDst are the renderings the pair/genre glossary fixes (src → dst). A variant that agrees with an - // industry convention for a surface it contains scores above one that contradicts it. - GenreDst map[string]string } // Consolidation factor bounds. They are multiplicative and all ≥ minFactor, so no single signal can zero a @@ -372,7 +373,6 @@ const ( freqFloor = 0.5 // a rendering proposed once keeps half the frequency factor conformBonus = 1.0 // full transliteration conformance can overturn a maximal frequency gap neighbourBonus = 0.5 - genreBonus = 0.6 lemmaPenalty = 0.4 // a visibly mangled form (trailing hyphen, stray bracket) is heavily demoted // stemMinPrefix is the ABSOLUTE floor of the common-prefix test below: fewer shared characters than // this is never a lexeme, whatever the word lengths. @@ -429,12 +429,7 @@ func ScoreVariants(c *Candidate, opts ScoreOpts) { score *= 1 + neighbourBonus v.Signals = append(v.Signals, "neighbour") } - // (d) agreement with the pair/genre convention for a component of the surface. - if genreAgrees(c.Key, v.Dst, opts.GenreDst) { - score *= 1 + genreBonus - v.Signals = append(v.Signals, "genre") - } - // (e) lemma completeness — a PROXY. The ratified factor is morphological (a complete case lemma), + // (d) lemma completeness — a PROXY. The ratified factor is morphological (a complete case lemma), // and the morphology analyser was dropped with default B; what is checkable without it is that the // form is not visibly mangled. Named as a proxy rather than dressed up as the real thing. if !wellFormedLemma(v.Dst) { @@ -486,40 +481,6 @@ func neighbourAgrees(src, dst string, ns []Neighbour) bool { return false } -// genreAgrees reports whether dst carries the industry rendering of a genre term the source surface -// contains (修炼 inside 修炼者 → the rendering should carry «культивация», in whatever form). -func genreAgrees(src, dst string, genre map[string]string) bool { - if src == "" || dst == "" || len(genre) == 0 { - return false - } - // Iterate the SOURCE, not the map: the surface is short, and a map walk here would make the score - // depend on iteration order the moment two conventions could both match. - have := wordSet(dst) - for _, gsrc := range substringsOf(src) { - gdst, ok := genre[gsrc] - if !ok || gdst == "" { - continue - } - if lexemeOverlap(have, wordSet(gdst)) { - return true - } - } - return false -} - -// substringsOf returns every contiguous rune substring of s, longest first — the lookup keys for a -// convention table keyed by whole terms. Bounded by the surface length, which is a handful of runes. -func substringsOf(s string) []string { - rs := []rune(s) - var out []string - for l := len(rs); l >= 1; l-- { - for i := 0; i+l <= len(rs); i++ { - out = append(out, string(rs[i:i+l])) - } - } - return out -} - // lexemeOverlap reports whether any word of a is the same LEXEME as any word of b (see stemMinPrefix). // Both sets are iterated through sorted keys so the result never depends on map order. func lexemeOverlap(a, b map[string]bool) bool { @@ -581,8 +542,8 @@ func sameLexeme(a, b string) bool { } // One form being a full prefix of the other counts as one lexeme ONLY once the shared stem is itself // long enough to be a stem. Without the floor the rule fires on any short word that happens to open a - // longer one — «гу» (蛊) against «Гуюэ» (the clan), «ад» against «адрес» — and the neighbour/genre - // factors then award their bonus to a rendering with no relation to the anchor at all. + // longer one — «гу» (蛊) against «Гуюэ» (the clan), «ад» against «адрес» — and the neighbour + // factor would then award its bonus to a rendering with no relation to the anchor at all. if (n == len(ar) || n == len(br)) && shorter >= stemMinPrefix { return true } @@ -705,27 +666,21 @@ func typeOr(t string) string { return t } -// Anchor markers. Bracketed engine tokens (the ⟦TM-BANK-v1⟧ idiom) rather than natural-language -// headings: a heading would be pair language living in Go, and the prompt is where every word the model -// reads about them belongs. CANON is the book's own SIGNED rows and is law; GENRE is the pair's industry -// convention and is a default. -const ( - CanonMarker = "⟦TM-CANON⟧" - GenreMarker = "⟦TM-GENRE⟧" -) +// CanonMarker labels the anchor block. A bracketed engine token (the ⟦TM-BANK-v1⟧ idiom) rather than a +// natural-language heading: a heading would be pair language living in Go, and the prompt is where every +// word the model reads about it belongs. CANON is the book's own SIGNED rows and is law. +// +// It is the ONLY anchor. A second block carrying pair/genre conventions shipped with pack-20 and was +// removed by D39.47: the market has no single register to anchor to («культивация» against +// «совершенствование» is a school, not a fact), so prescribing one to every book of the pair would have +// overridden the only authority that exists here — the owner's signature on THIS book's bank. +const CanonMarker = "⟦TM-CANON⟧" -// RenderAnchors serializes the two anchor blocks the terminologist reads before it decides. Either may be -// empty; both empty → "" and the caller sends no anchor message at all, so a pair with no glossary and a -// book with no signed rows take a byte-identical path to before. -func RenderAnchors(canon, genre [][2]string) string { - var blocks []string - if b := renderPairs(CanonMarker, canon); b != "" { - blocks = append(blocks, b) - } - if b := renderPairs(GenreMarker, genre); b != "" { - blocks = append(blocks, b) - } - return strings.Join(blocks, "\n\n") +// RenderCanonAnchor serializes the signed rows the terminologist reads before it decides. Empty → "" and +// the caller sends no anchor message at all, so a book with no signed rows takes a byte-identical path to +// before the anchor existed. +func RenderCanonAnchor(canon [][2]string) string { + return renderPairs(CanonMarker, canon) } func renderPairs(marker string, pairs [][2]string) string { diff --git a/backend/internal/terminology/terminology_test.go b/backend/internal/terminology/terminology_test.go index 783cfb9d..dccaa3ba 100644 --- a/backend/internal/terminology/terminology_test.go +++ b/backend/internal/terminology/terminology_test.go @@ -199,7 +199,7 @@ func TestScoreVariantsIsNotMajority(t *testing.T) { } } -func TestScoreVariantsNeighbourAndGenreAndMalformed(t *testing.T) { +func TestScoreVariantsNeighbourAnchorAndMalformed(t *testing.T) { // A rendering that agrees with an APPROVED sibling of the same source series outranks a MORE FREQUENT // one that does not. The competing rendering deliberately shares no lexeme with the signed sibling and // leads on count — otherwise both variants take the bonus, the winner falls to the alphabetical @@ -222,14 +222,23 @@ func TestScoreVariantsNeighbourAndGenreAndMalformed(t *testing.T) { if bare.Best() != "пламя духа" { t.Fatalf("without the neighbour anchor the count must decide, else the test proves nothing: %+v", bare.Variants) } - // The genre convention pulls its own way. + // D39.47: there is no pair/genre factor to test beside the neighbour one. The formula's ONLY consistency + // anchor is the signed bank, and a rendering nobody signed is decided by count and the pair's + // transliteration table — not by an industry default the market does not actually have. The competing + // «совершенствующийся»/«практик культивации» case that used to live here is now, correctly, a count + // decision until the owner signs one of them. g := Candidate{Key: "修炼者", Src: "修炼者", Type: "term", Variants: []Variant{ {Dst: "совершенствующийся", Chunks: 3}, {Dst: "практик культивации", Chunks: 1}, }} - ScoreVariants(&g, ScoreOpts{GenreDst: map[string]string{"修炼": "культивация"}}) - if g.Best() != "практик культивации" { - t.Fatalf("the industry convention must outrank a frequent contradiction: %+v", g.Variants) + ScoreVariants(&g, ScoreOpts{}) + if g.Best() != "совершенствующийся" { + t.Fatalf("with nothing signed the count decides — no register is imposed on the book: %+v", g.Variants) + } + for _, v := range g.Variants { + if contains(v.Signals, "genre") { + t.Fatalf("the genre factor is removed (D39.47); a signal naming it means it came back: %+v", v) + } } // A SHORT word that merely opens a longer one is not the same lexeme: «гу» (蛊, the book's own term) // against «Гуюэ» (a clan), «ад» against «адрес». Without the floor the anchor factors fire on @@ -282,12 +291,13 @@ func TestCanonForRanksContainmentFirstAndCaps(t *testing.T) { t.Fatalf("a row must not anchor itself: %v", self) } // Nothing signed → no block at all, so a book with no canon takes the pre-existing path byte for byte. - if a := RenderAnchors(nil, nil); a != "" { - t.Fatalf("empty anchors must render to nothing, got %q", a) + if a := RenderCanonAnchor(nil); a != "" { + t.Fatalf("an empty anchor must render to nothing, got %q", a) } - if a := RenderAnchors([][2]string{{"元海", "море истинной ци"}}, [][2]string{{"修炼", "культивация"}}); !strings.HasPrefix(a, CanonMarker) || - !strings.Contains(a, GenreMarker) || strings.Index(a, CanonMarker) > strings.Index(a, GenreMarker) { - t.Fatalf("law before default: %q", a) + // One marker, one block, and the signed rows verbatim — D39.47 left exactly one anchor, so the rendering + // is fully pinned here rather than probed for a prefix. + if a := RenderCanonAnchor([][2]string{{"元海", "море истинной ци"}, {"空窍", "апертура"}}); a != CanonMarker+"\n元海\tморе истинной ци\n空窍\tапертура" { + t.Fatalf("canon anchor rendering = %q", a) } } diff --git a/backend/prompts/zh-ru/terminologist.md b/backend/prompts/zh-ru/terminologist.md index 125f7c17..a9812436 100644 --- a/backend/prompts/zh-ru/terminologist.md +++ b/backend/prompts/zh-ru/terminologist.md @@ -1,7 +1,12 @@ - + Ты — терминолог издательского перевода с языка «{{source_lang}}» на язык «{{target_lang}}». Книга: «{{title}}». Жанр: {{genre}}. Аудитория: {{audience}}. @@ -25,9 +30,6 @@ prompts/<пара>/<роль>.md, грузится как шаблон, в сн из списка содержит подписанный элемент исходника, перевод ОБЯЗАН нести подписанный перевод этого элемента. Противоречить ⟦TM-CANON⟧ нельзя, даже если черновики предложили другое и даже если тебе кажется, что твой вариант красивее: единство книги важнее отдельной удачной формулировки. -- Блок ⟦TM-GENRE⟧ — общепринятые соответствия жанра. Это умолчание для терминов, которых нет в каноне: - следуй ему для терминов, которые в него входят или содержат его элементы. При конфликте ⟦TM-CANON⟧ - побеждает. - Имена и топонимы передавай транскрипцией по заданной системе; титулы, звания и реалии переводи ПО СМЫСЛУ, а не транслитерацией. - Сохраняй родство однокоренных терминов: если несколько терминов делят элемент исходника, их переводы diff --git a/docs/archive/reports/PACK20_DELTA_2026-07-26.md b/docs/archive/reports/PACK20_DELTA_2026-07-26.md new file mode 100644 index 00000000..327813b8 --- /dev/null +++ b/docs/archive/reports/PACK20_DELTA_2026-07-26.md @@ -0,0 +1,192 @@ +# Пак-20, ДЕЛЬТА: размотка жанрового словаря (D39.47) + дизайн-секция по расширению эмиссии + +> **Ревью-шапка (оркестратор №8, 26.07): РАЗМОТКА ПРИНЯТА И ЗАЛЕНДЕНА (D39.48).** Проверено исполнением: +> остатков жанрового механизма в Go нет (grep по GenreGlossary/genreAgrees/TM-GENRE — только +> документирующая строка в промпте роли), файл данных удалён; `go vet` чист, целевые пакеты +> (lang/terminology/pipeline) `-race` зелёные, golden PASS БЕЗ пере-капчера — что независимо доказывает +> клейм «версия langpack побайтно та же» (LangpackVersion фолдится в снапшот, сдвиг уронил бы golden). +> Отклонение (пере-писанная интерим-шапка промпта роли) — принято: фаза B действительно закрыта. +> Три синк-находки §2 (три потребителя эмиссии и переформулировка оси · доля отказов роли на мусоре +> не измерена · kwic-дефолты не покрыты замером) — подтверждены чтением кода (`attachConsolidatedDst` +> действительно матчит только по ключам mined) и вместе с вопросом совместного recall (майнер∪банкнота) +> составляют пре-замеры перед дизайном расширения; санкции — D39.48. + +> **Статус: размотка ИСПОЛНЕНА, дизайн-секция §2 — ПРЕДЛОЖЕНИЕ, стройка НЕ начата.** Записка оркестратора №8 +> от 26.07.2026 (D39.47) получена релеем и исполнена по букве: снято ровно перечисленное, ⟦TM-CANON⟧-якоря и +> `CanonConflicts` не тронуты, узкое правило транскрипции в промпте роли не тронуто. +> **Зона:** `backend/` + этот отчёт. Полигонские `eval/pkg7/*` и правки отчётов пакета-7 — чужие, не тронуты. +> **Сессия НЕ коммитит.** Деньги сессии: **$0** — ни одного платного вызова (размотка и проверки офлайновы). +> Предыдущий отчёт пака: [`PACK20_BANK_BUILD_2026-07-26.md`](PACK20_BANK_BUILD_2026-07-26.md). + +--- + +## §1. Размотка — что снято и чем это доказано + +Все адреса записки сверены по HEAD (`e618b57`) и отработаны. + +| файл | что сделано | +|---|---| +| `internal/terminology/terminology.go` | снят `ScoreOpts.GenreDst`; снят фактор (d) `genreAgrees`/`genreBonus` из `ScoreVariants`; снята константа `genreBonus`; снята функция `genreAgrees`; снята `substringsOf` (её единственный потребитель — `genreAgrees`); `GenreMarker` снят, `RenderAnchors(canon, genre)` → `RenderCanonAnchor(canon)` | +| `internal/pipeline/terminologist.go` | снята `genreGlossaryMap()`; из `ScoreOpts` ушла передача; из `terminologyMessages` ушла сборка ⟦TM-GENRE⟧-блока | +| `internal/lang/langpack.go` | снято поле `Pack.GenreGlossary`, тип `GenreTerm`, `parseGenreGlossary`, `GenreGlossaryFor`, опциональное чтение файла в `Load` | +| `configs/langpacks/zh-ru/genre-glossary.txt` | **удалён** (`rm`, не `git rm` — индекс не трогаю, лендинг ваш) | +| `prompts/zh-ru/terminologist.md` | снят абзац про ⟦TM-GENRE⟧; шапка переписана с «ИНТЕРИМ, ждёт фазу B» на v2 с итогами фазы B (см. §1.3) | +| тесты | `langpack_test.go` — сняты три теста механизма словаря и хелпер `mirrorPackWithout`; `terminology_test.go` — снят пин фактора и пин двух-блочного якоря; `miningstop_join_test.go` — пин «канон перед жанром» заменён пином «канон — ЕДИНСТВЕННЫЙ блок» | + +Осталось нетронутым по требованию записки: ⟦TM-CANON⟧-якорь и `CanonFor`/`CanonConflicts`; узкое правило транскрипции +в промпте роли; факторы частоты, конформанса, соседа и леммы. **Формула §C2-3 вернулась ровно к ратифицированным +четырём множителям** — лишний пятый был как раз жанровым. + +### 1.1 Мутации (обязательный пункт записки: «вернуть фактор» должно быть красным) + +| # | мутация | результат | +|---|---|---| +| **M1** | фактор возвращён руками в `ScoreVariants` (жёстко зашитый `修炼 → культивация`, ×1.6) | **КРАСНАЯ**: `TestScoreVariantsNeighbourAnchorAndMalformed` — `практик культивации` обошёл `совершенствующийся` при 3 против 1, и в `Signals` появился `genre` | +| **M2** | второй якорный блок возвращён руками в `RenderCanonAnchor` | **КРАСНАЯ ДВАЖДЫ**: юнит `TestCanonForRanksContainmentFirstAndCaps` (пин рендера побайтно) и провод `TestCanonAnchorPutsTheSignedBankInFrontOfTheTerminologist` (на проводе найдено 2 якорных маркера вместо 1) | +| **M3** | контрольная (обратная): правка `surnames-single.txt` и `palladius.txt` | **КРАСНАЯ** без правки — новый `TestPairDataEditMovesThePackVersion` требует движения версии на любой авторский файл пака | + +Пин проводом (M2) сделан **обобщённым, а не по строке `TM-GENRE`**: тест считает все `⟦TM-` на проводе роли и +требует, чтобы их было ровно столько же, сколько `⟦TM-CANON⟧`. Любой второй якорный блок — как бы он ни +назывался — красит его. Это дешевле, чем пин конкретного удалённого маркера, и переживает переименования. + +### 1.2 Оси — проверено исполнением, а не заявлено + +- **Снапшоты книг и волны НЕ двигаются.** Проверил прогоном на копии шиппингового пака: файл был вне + `pack.Version()`, поэтому его удаление даёт **побайтно ту же версию** (`langpack-v2-a28ed743c99c` до и после + восстановления файла в каталог пары). `LangpackVersion` — единственный вход пака в снапшот + ([snapshot.go:428](../../backend/internal/pipeline/snapshot.go#L428)), значит ни один юнит ни одной книги не + пере-оплачивается и `--resnapshot` не нужен. Проба-скрипт удалён после замера. +- **Пере-покупаются только батчи терминолога.** Байты промпта роли и отрендеренный якорь входят в + request-hash вызовов класса `terminologist`; per-stage `PromptSHA256` терминолога не видит (он не стадия, а + адресный лейбл), `gates.terminology` не фолдится намеренно. +- **Мёртвого кода не осталось:** `go build`, `go vet` чисты; `gofmt -l` показывает только досессионный + `internal/llm/llm.go`, которого я не касался. + +### 1.3 Одно отклонение от буквы записки — объявляю, а не прячу + +Записка перечисляла снятия и не говорила про шапку промпта роли. Я её переписал: она заявляла «ИНТЕРИМ v1, +финальный текст выберет фаза B», а фаза B **уже исполнена и принята** (D39.46). Оставить шапку значило бы +держать в бою заведомо неверную запись о статусе. Новая шапка фиксирует то, что фаза B измерила (контексты — +главный рычаг; лемма; батчи; узкая форма правила транскрипции — с указанием, что широкая опровергнута T2, а +полное снятие ломает `月光蛊`/`王婆`) и что ⟦TM-GENRE⟧ снят по D39.47. **Текст самих правил роли не изменён**, кроме +удаления жанрового абзаца. Если это лишнее — откат в одну правку данных, Go не задет. + +### 1.4 Верификация + +`go test ./...` зелёный · `-race` зелёный на `pipeline`/`terminology`/`lang`/`miner`/`membank` · +`TestGoldenDeterminism` PASS (инвариант детерминизма №8 держится) · `go vet` чист. + +--- + +## §2. Дизайн-секция: чем закрывать покрытие вместо словаря — СТРОЙКА НЕ НАЧАТА + +Связывающее ограничение теперь одно: **эмиссия**. `修行` ×326 не увидит никто, потому что +`emissionEligible` требует тип ∈ {name, place, title}, а у доменного понятия паттерна нет +([miner_emit.go:302-313](../../backend/internal/miner/miner_emit.go#L302-L313)). + +### 2.0 Находка чтением кода, которая меняет форму вопроса: потребителей у эмиссии ТРИ, а потолок один + +G7 сформулирован как «показ владельцу ≠ подача терминологу». По коду их не два, а **три**, и один +`emitRankCap=200` обслуживает все: + +| # | потребитель | что ему нужно | где в коде | +|---|---|---|---| +| **C1** | лист на ПОДПИСЬ владельцу | объём, который человек физически подпишет (17 строк — да, 15 418 — нет) | `signatureMapPath()`, стоп-таблица | +| **C2** | АВТО-банк (D39.42 п.3): строки ⟨проверить⟩, которые едут в редактора и в следующие черновики | покрытие; подпись НЕ требуется | `writeAutoBank` → `seedGlossary` ([mining.go:176-181](../../backend/internal/pipeline/mining.go#L176-L181)) | +| **C3** | вход ТЕРМИНОЛОГА | покрытие; цена = токены | `buildBankCandidates` → `runTerminologist` | + +**И вот что важно для решения: расширять один только вход роли (C3) почти ничего не даёт.** Проверено чтением +потока: консолидация приклеивается перебором строк ДЕЛЬТЫ с поиском по ключу +([terminologist.go:384-396](../../backend/internal/pipeline/terminologist.go#L384-L396), вызов — +[mining.go:141](../../backend/internal/pipeline/mining.go#L141)), поэтому dst кандидата, которого в `mined` +нет, **в банк не попадает**: он виден только в сайдкар-таблице стопа (она рендерится из полного `cands`, +[mining.go:253-266](../../backend/internal/pipeline/mining.go#L253-L266)) и умирает вместе с прогоном — ни в +инъекцию редактору, ни в следующий черновик, ни в лист подписи. Значит recall лечится только расширением +того, что попадает в `mined`, то есть C2, а C1 обязан остаться человеческого размера. Правильная формулировка +оси, по-моему, не «показ ≠ подача», а **«что БАНКУЕТСЯ без подписи ≠ что кладётся НА ПОДПИСЬ»**; подача роли +едет вместе с первым. + +### 2.1 (а) Потолок и фильтр типа — три варианта, все на данных абляции полигона + +Абляция фазы A (gu25, 56 термов сида в срезе): A — как в проде 17 строк / recall 0.089 · B — снят `emitRankCap` +624 / **0.429** · C — B + снят фильтр типа 13 246 / **0.875** · D — C + снята длина ≥2 15 418 / **0.911**; +precision спот-чек по C ≈ **0.15** (один разметчик). + +| вариант | что снимается | объём (gu25) | recall | деньги роли при kwic 3×40 | оси | +|---|---|---|---|---|---| +| **а1** | `emitRankCap` поднимается до N (конфиг), фильтр типа остаётся | 624 при N=∞ | 0.429 | ≈$0.05 | самый дешёвый; жанровые понятия по-прежнему НЕ придут (это фильтр типа, не потолок) | +| **а2** | а1 + фильтр типа снят ДЛЯ БАНКА (C1 остаётся под потолком) | 13 246 | 0.875 | ≈$1.10 | закрывает `修行`/`蛊师`/`真元`; вносит ~11k шумных строк в авто-банк — см. риск 2.1.1 | +| **а3** | а2 + одноиероглифные ПО ЧАСТОТЕ (не сплошь): `runeLen==1` допускается, если терм в топ-K книги по частоте либо является головой ≥2 других кандидатов | 13 246 + единицы | ~0.88+ | +≈$0 | адресно возвращает `蛊` (8700×) и `转` (2171×) — заглавные понятия книги — не покупая +2 172 строки варианта D | + +**Замер, которого нет ни у кого и который решает вопрос (риск 2.1.1).** При precision ≈0.15 расширение +кладёт в авто-банк ~11k мусорных кандидатов (`便是`, `的关`, `动的`). Мусор безвреден ровно до тех пор, пока +роль его **отклоняет** (⟦TM-NO-DST⟧ → `status:auto`, без dst, в инъекцию не попадает). Мусор, которому роль +ВЫДУМАЛА перевод, становится строкой `draft` ⟨проверить⟩ и едет в память редактора. **Доля отклонений на +мусорном хвосте — единственное неизвестное, отделяющее «расширение работает» от «расширение отравляет банк», +и она не измерена ничем: набор фазы B состоял из настоящих термов.** Предлагаю мерить ДО стройки: 100 +кандидатов из хвоста варианта C, один прогон, ≈**$0.01**. Критерий назвать заранее (моё предложение: доля +отклонений на заведомом мусоре ≥0.9, иначе расширение требует второго фильтра). +- **Второй фильтр, если понадобится, уже ратифицирован как годный:** D39.46 п.2 — слепой судья с декоем + годен как **катастроф-экран** (разрыв до декоя 1.27–1.42 против межарменного 0.20). Это ровно задача + «отсечь грубый мусор, не ранжируя тонкое». То есть при провале критерия ответ известен и не нов. +- **Инженерная цена, названная честно:** кластеризация алиасов — `proposeAliasEdges`, полный перебор пар + ([miner_alias.go:80-82](../../backend/internal/miner/miner_alias.go#L80-L82)). 200 кандидатов = 19 900 пар, + 13 246 = **87.7 млн** пар, часть с посентенсным сканом текста. Полигон прогонял абляцию на 25 главах, значит + это терминируется; на 100 главах и на приёмочной книге (где детектор сам квадратичен, A3.7) **не мерено ни + разу**. Чинится дёшево, если подача роли собирается ДО кластеризации (роли нужны поверхности с контекстами, + а кластеры нужны подписи) — но это уже решение о стройке, и я его не принимаю. + +### 2.2 (б) `feed_cap` — сколько подаётся роли + +Предложение прежнее (D39.45), с деньгами и без тихой вставки: `gates.terminology.feed_cap` — сколько +кандидатов уезжает в роль, ранжирование тем же §C2-3, **таблица владельцу показывает ВСЁ**, в лог — «подано N +из M». Измерено мной на живом прогоне: **223 входных токена на кандидата** при `kwic_per_term: 3, +kwic_width: 40` (7 799 токенов на 35 кандидатов). + +| подача | вход, токенов | цена книги | что покрывает | +|---|---|---|---| +| 200 (сегодня де-факто) | ~45k | ≈$0.02 | recall 0.089 | +| 400 | ~89k | ≈**$0.035** | recall ~0.2–0.4 | +| 13 246 (весь ранжированный набор варианта C) | ~2.95M | ≈**$1.10** | recall 0.875 | + +Оси: `feed_cap` — **деньги**, а не качество; он не решает recall (см. §2.0 — подача без банка теряется) и +нужен как предохранитель на случай книги, где ранжированный набор окажется на порядок больше ожидаемого. +Сегодняшний бюджет-гейт (теперь предварительный) страхует по деньгам, но режет **хвост батча**, а не наименее +ценное — вот единственная содержательная разница. + +### 2.3 (в) `kwic_per_term` / `kwic_width` — самая тонкая дыра и самая дешёвая её проверка + +D39.46 ратифицировал «контексты — главный рычаг» (арм без контекстов теряет 6–7 совпадений с подписью из 19 +при внутриармовом размахе 1). **Но замер сделан на конфигурации полигона ~8 контекстов × ~120 символов +(§A5.5), а в бою стоят мои дефолты 3 × 40** — то есть ⅛ объёма контекста. Измерены две точки, 0 и ~8×120; +наша точка лежит между ними и ничем не покрыта. Формально ратифицированный вывод верен, а вот **дефолт, +который реально едет в книгу, не аттестован ни разу** — и это мой долг, а не полигонский. + +**Предлагаемый замер (свой арм на каждый фактор — норма D39.46(а)):** тот же harness `terminologist_arms.py` +на тех же 19 термах с подписью владельца, армы `kwic 0` (контроль, он уже есть — P0b) · `3×40` (наш дефолт) · +`8×120` (точка полигона) · `8×40` и `3×120` (развязка «сколько контекстов» против «какой ширины»), по 3 +повтора. По их же фактическим цифрам повтор = 12 вызовов ≈ $0.008 ⇒ **весь замер ≈ $0.12**. Критерий назвать +заранее: если `3×40` не хуже `8×120` больше чем на 1 терм из 19 (размах повторов у них = 1), дефолт +подтверждён и дешёвый; иначе дефолт двигается, и цена подачи умножается вместе с ним. + +**Почему это нельзя решать отдельно от (а) и (б):** цена роли = `подача × объём контекста`. При `8×120` +per-candidate вход растёт примерно вчетверо-вшестеро, и «весь банк» уезжает с ≈$1.10 к **$4–7/книга**. Три +ручки — один бюджет; развилка «широкая подача с узким контекстом» против «узкая подача с широким контекстом» +на сегодня не измерена вовсе, и по-моему именно она — настоящий вопрос фазы 2, а не выбор потолка. + +**Когда мерить:** до стройки расширения и вместе с замером отклонений (§2.1) — оба идут на одном harness, +суммарно ≈$0.13, оба дают критерий ДО трат на код. + +--- + +## §3. Что на владельце / оркестраторе + +| # | пункт | почему это не моё решение | +|---|---|---| +| **1** | **Расширять C2 (авто-банк без подписи), а не C1 (лист на подпись)** — подтвердить формулировку оси | это решение о том, сколько неподписанного движок кладёт в память книги; принцип D39.47 («больше строк на решение, ничего не решая за владельца») формально выполняется и в том, и в другом варианте, но цена разная | +| **2** | Санкция на **два дешёвых замера ДО стройки** (≈$0.13 суммарно): доля отклонений роли на мусорном хвосте · кривая kwic между 0 и 8×120 | оба подрывают/подтверждают посылку стройки, а не её исполнение — правило №4 D39.47 требует поднять это, а не строить формально | +| **3** | Вариант расширения: **а1 / а2 / а3** | деньги и объём различаются на порядок | +| **4** | Дефект кластеризации `葛家 ⊃ 族长` (G8) — чинить ли САМУ кластеризацию | минимум пака-20 исполнен (поверхность не теряется при сборке входа, провенанс `proposed for 族长` виден); сам фикс — отдельное решение, как и сказано в аддендуме | +| **5** | Досессионные долги из отчёта пака-20 остаются: `AttachKWIC` квадратичен (59 с на книжном масштабе) · чекпойнт роли — по прогону, а не по батчу · `bankTokenBudget` выведен из зашитого zh→ru worst case | не трогал, чужих решений не принимал | + +**СТОП. Лендинг — оркестратора. Сессия не коммитила.**