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:
parent
e618b57aae
commit
1b19345ec4
9 changed files with 286 additions and 374 deletions
|
|
@ -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, 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):
|
||||
# 修炼 культивация
|
||||
# 修士 культиватор
|
||||
# 筑基 закладка основания
|
||||
修真 культивация
|
||||
修行 культивация
|
||||
丹田 даньтянь
|
||||
经脉 меридианы
|
||||
灵气 духовная энергия
|
||||
金丹 золотое ядро
|
||||
|
|
@ -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{}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,12 @@
|
|||
<!-- ИНТЕРИМ v1 (аддендум оркестратора 26.07). Финальный текст роли выбирает полигонская фаза B —
|
||||
слепой эксперимент на 6 армах — и он ложится сюда ПРАВКОЙ ДАННЫХ: файл резолвится конвенцией
|
||||
prompts/<пара>/<роль>.md, грузится как шаблон, в снапшот НЕ фолдится и ни один тест не пинит его текст.
|
||||
Смена текста стоит переоплаты только батчей терминолога (их адрес включает байты запроса), Go не трогается. -->
|
||||
<!-- v2 (26.07). Фаза B полигона исполнена и принята (D39.46, 6 армов + B′): подтверждены KWIC-контексты
|
||||
как ГЛАВНЫЙ рычаг (арм без них теряет 6–7 совпадений с подписью владельца из 19 при внутриармовом
|
||||
размахе 1), требование леммы и батчи ~10 термов. Правило транскрипции оставлено в УЗКОЙ форме
|
||||
(люди и места — транскрипция; предметы, приёмы, существа — по смыслу): широкая форма опровергнута
|
||||
замером T (P2−P5 = +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⟧
|
||||
побеждает.
|
||||
- Имена и топонимы передавай транскрипцией по заданной системе; титулы, звания и реалии переводи
|
||||
ПО СМЫСЛУ, а не транслитерацией.
|
||||
- Сохраняй родство однокоренных терминов: если несколько терминов делят элемент исходника, их переводы
|
||||
|
|
|
|||
192
docs/archive/reports/PACK20_DELTA_2026-07-26.md
Normal file
192
docs/archive/reports/PACK20_DELTA_2026-07-26.md
Normal 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.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 | не трогал, чужих решений не принимал |
|
||||
|
||||
**СТОП. Лендинг — оркестратора. Сессия не коммитила.**
|
||||
Loading…
Add table
Reference in a new issue