Land pack twenty delta: genre glossary machinery unwound per D39.47, canon anchors and narrow transcription rule kept, targeted suite and golden green

This commit is contained in:
Claude (backend session) 2026-07-26 17:51:38 +03:00
parent e618b57aae
commit 1b19345ec4
9 changed files with 286 additions and 374 deletions

View file

@ -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: `src<TAB>dst[<TAB>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, V0V3, 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, V0V3):
# 修炼 культивация
# 修士 культиватор
# 筑基 закладка основания
修真 культивация
修行 культивация
丹田 даньтянь
经脉 меридианы
灵气 духовная энергия
金丹 золотое ядро

View file

@ -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/<pair>/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/<pair>/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: `src<TAB>dst[<TAB>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 `src<TAB>dst[<TAB>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{}

View file

@ -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")
}
}

View file

@ -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)
}
}

View file

@ -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

View file

@ -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 {

View file

@ -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)
}
}

View file

@ -1,7 +1,12 @@
<!-- ИНТЕРИМ v1 (аддендум оркестратора 26.07). Финальный текст роли выбирает полигонская фаза B —
слепой эксперимент на 6 армах — и он ложится сюда ПРАВКОЙ ДАННЫХ: файл резолвится конвенцией
prompts/<пара>/<роль>.md, грузится как шаблон, в снапшот НЕ фолдится и ни один тест не пинит его текст.
Смена текста стоит переоплаты только батчей терминолога (их адрес включает байты запроса), Go не трогается. -->
<!-- v2 (26.07). Фаза B полигона исполнена и принята (D39.46, 6 армов + B): подтверждены KWIC-контексты
как ГЛАВНЫЙ рычаг (арм без них теряет 67 совпадений с подписью владельца из 19 при внутриармовом
размахе 1), требование леммы и батчи ~10 термов. Правило транскрипции оставлено в УЗКОЙ форме
(люди и места — транскрипция; предметы, приёмы, существа — по смыслу): широкая форма опровергнута
замером T (P2P5 = +0.013 при пороге 0.05), а полное снятие правила ломает 月光蛊 и 王婆.
Блок ⟦TM-GENRE⟧ снят по D39.47 — жанрового словаря нет как класса.
Файл остаётся ДАННЫМИ: резолвится конвенцией prompts/<пара>/<роль>.md, грузится как шаблон, в снапшот
НЕ фолдится и ни один тест не пинит его текст. Смена текста стоит переоплаты только батчей терминолога
(их адрес включает байты запроса), Go не трогается. -->
Ты — терминолог издательского перевода с языка «{{source_lang}}» на язык «{{target_lang}}».
Книга: «{{title}}». Жанр: {{genre}}. Аудитория: {{audience}}.
@ -25,9 +30,6 @@ prompts/<пара>/<роль>.md, грузится как шаблон, в сн
из списка содержит подписанный элемент исходника, перевод ОБЯЗАН нести подписанный перевод этого
элемента. Противоречить ⟦TM-CANON⟧ нельзя, даже если черновики предложили другое и даже если тебе
кажется, что твой вариант красивее: единство книги важнее отдельной удачной формулировки.
- Блок ⟦TM-GENRE⟧ — общепринятые соответствия жанра. Это умолчание для терминов, которых нет в каноне:
следуй ему для терминов, которые в него входят или содержат его элементы. При конфликте ⟦TM-CANON⟧
побеждает.
- Имена и топонимы передавай транскрипцией по заданной системе; титулы, звания и реалии переводи
ПО СМЫСЛУ, а не транслитерацией.
- Сохраняй родство однокоренных терминов: если несколько терминов делят элемент исходника, их переводы

View file

@ -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.271.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.20.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 ратифицировал «контексты — главный рычаг» (арм без контекстов теряет 67 совпадений с подписью из 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 к **$47/книга**. Три
ручки — один бюджет; развилка «широкая подача с узким контекстом» против «узкая подача с широким контекстом»
на сегодня не измерена вовсе, и по-моему именно она — настоящий вопрос фазы 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 | не трогал, чужих решений не принимал |
**СТОП. Лендинг — оркестратора. Сессия не коммитила.**