From 9f60746c0cac829a5df0c9c2a63f06b1dc25d84b Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sun, 2 Aug 2026 01:50:20 +0300 Subject: [PATCH] Land smallpack tails as D39.82: eight checker review tails, bucket D cleanup, quality tails, tmctl backup with production preflight, register moved to book layer, DC7 regated on seed coverage --- backend/cmd/tmctl/backup.go | 66 +++++++ backend/cmd/tmctl/backup_test.go | 74 ++++++++ backend/cmd/tmctl/main.go | 18 +- backend/cmd/tmctl/render.go | 3 + .../configs/langpacks/zh-ru/dc-checkers.txt | 16 +- backend/example/book.yaml | 5 + backend/internal/checks/cheapgates.go | 26 ++- backend/internal/checks/checkers.go | 63 ++++--- .../internal/checks/checkers_pairdata_test.go | 28 ++- .../internal/checks/checkers_zh_ru_test.go | 39 ++++- .../internal/checks/labelcandidates_test.go | 32 ++-- backend/internal/checks/repair.go | 5 +- backend/internal/chunk/chunker.go | 6 +- backend/internal/chunk/ingest.go | 11 +- backend/internal/config/book.go | 16 +- backend/internal/lang/langpack.go | 37 +++- backend/internal/lang/langpack_test.go | 34 ++++ backend/internal/llm/llm.go | 6 +- backend/internal/membank/mempostcheck.go | 6 +- backend/internal/membank/mempostcheck_test.go | 36 +++- backend/internal/miner/miner_patterns.go | 7 + backend/internal/miner/miner_substrate.go | 12 -- backend/internal/pipeline/chunkrun.go | 3 +- .../internal/pipeline/miningstop_join_test.go | 4 +- backend/internal/store/backup.go | 102 +++++++++++ backend/internal/store/backup_test.go | 79 +++++++++ backend/internal/terminology/classify_test.go | 20 +-- backend/internal/terminology/series_test.go | 4 +- backend/prompts/zh-ru/editor-mono.md | 5 + docs/PROGRESS.md | 12 +- docs/README.md | 2 +- docs/architecture/05-decisions-log.md | 6 +- .../BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md | 4 +- .../reports/SMALLPACK_TAILS_2026-08-02.md | 163 ++++++++++++++++++ 34 files changed, 834 insertions(+), 116 deletions(-) create mode 100644 backend/cmd/tmctl/backup.go create mode 100644 backend/cmd/tmctl/backup_test.go create mode 100644 backend/internal/store/backup.go create mode 100644 backend/internal/store/backup_test.go rename docs/{ => archive/prompts}/BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md (96%) create mode 100644 docs/archive/reports/SMALLPACK_TAILS_2026-08-02.md diff --git a/backend/cmd/tmctl/backup.go b/backend/cmd/tmctl/backup.go new file mode 100644 index 00000000..4bb575ff --- /dev/null +++ b/backend/cmd/tmctl/backup.go @@ -0,0 +1,66 @@ +package main + +// backup.go: the F4 backup/integrity command and the automatic pre-flight guard for the PAID boevoy +// commands (backlog row 83). The guard lives at the boevoy COMMAND dispatch (run()), NOT inside translate() +// or the pipeline Runner — so the "is this a real run?" discriminator carries NO provider-name magic string +// (generality §0: Go must not branch on a fake-vs-real provider literal). A fake-provider test drives the +// Runner or translate() directly and never enters run()'s dispatch, so the golden/fixture paths create zero +// backup files without any special-casing here. + +import ( + "fmt" + "io" + "os" + "path/filepath" + "time" + + "textmachine/backend/internal/config" + "textmachine/backend/internal/store" +) + +// backupStamp formats a UTC timestamp for a backup filename — sortable and filesystem-safe. +func backupStamp(t time.Time) string { return t.UTC().Format("20060102T150405Z") } + +// backupDirFor returns the durable backup directory for a book: backups/ next to its project DB, which for +// the stand book lands under ~/books//… — the ratified durable home ("persist, not scratch"). +func backupDirFor(dbPath string) string { return filepath.Join(filepath.Dir(dbPath), "backups") } + +// backupCmd is the `tmctl backup` verb: after a green PRAGMA integrity_check it VACUUM-INTO-copies the book's +// SQLite file to a fresh timestamped restore point (F4). $0, no provider keys — an operator safety command, +// like status/report. It refuses loudly if the database is missing or fails integrity. +func backupCmd(cfgPath string, w io.Writer) error { + book, err := config.LoadBook(cfgPath) + if err != nil { + return err + } + if _, err := os.Stat(book.ProjectDB); err != nil { + return fmt.Errorf("tmctl backup: project database %s does not exist yet — run `tmctl translate` first, there is nothing to back up", book.ProjectDB) + } + path, err := store.BackupSQLite(book.ProjectDB, backupDirFor(book.ProjectDB), backupStamp(time.Now())) + if err != nil { + return err + } + fmt.Fprintf(w, "backup OK: %s (integrity_check green, VACUUM INTO)\n", path) + return nil +} + +// preflightBackup is the automatic pre-run guard for the PAID boevoy commands (translate, non-dry-run +// redrive): a paid run of a book must not start without a fresh restore point and a green PRAGMA +// integrity_check (F4 — the SPOF is a silent loss of the owner's signed bank and checkpoints). A missing DB +// is a fresh book with no signed bank to lose (skip, no error); an integrity failure is a LOUD refusal to +// start, so a paid run never writes on top of a corrupt file. +func preflightBackup(cfgPath string, w io.Writer) error { + book, err := config.LoadBook(cfgPath) + if err != nil { + return err + } + if _, err := os.Stat(book.ProjectDB); err != nil { + return nil // first run of a new book: no signed bank exists yet, nothing to back up + } + path, err := store.BackupSQLite(book.ProjectDB, backupDirFor(book.ProjectDB), backupStamp(time.Now())) + if err != nil { + return fmt.Errorf("pre-flight guard: refusing to start a paid run that could lose the signed bank: %w", err) + } + fmt.Fprintf(w, "pre-flight: backed up the project DB to %s (integrity_check green)\n", path) + return nil +} diff --git a/backend/cmd/tmctl/backup_test.go b/backend/cmd/tmctl/backup_test.go new file mode 100644 index 00000000..686e2514 --- /dev/null +++ b/backend/cmd/tmctl/backup_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "textmachine/backend/internal/pipeline" + "textmachine/backend/internal/store" +) + +// TestPreflightBackupCreatesRestorePoint pins the F4 boevoy guard: a book whose project DB already exists +// gets a fresh, integrity-checked backup before the paid run. +func TestPreflightBackupCreatesRestorePoint(t *testing.T) { + bookPath := setupCLIProject(t, "http://127.0.0.1:1") // no provider call on this path — only the config loads + dbPath := filepath.Join(filepath.Dir(bookPath), "cli-book.db") + s, err := store.Open(dbPath) // a prior run would have created this + if err != nil { + t.Fatal(err) + } + s.Close() + + var buf bytes.Buffer + if err := preflightBackup(bookPath, &buf); err != nil { + t.Fatalf("preflight: %v", err) + } + backups, _ := filepath.Glob(filepath.Join(filepath.Dir(dbPath), "backups", "*.db")) + if len(backups) != 1 { + t.Fatalf("the pre-flight must leave exactly one restore point, got %d", len(backups)) + } + if !strings.Contains(buf.String(), "integrity_check green") { + t.Errorf("the pre-flight must report the integrity check, got %q", buf.String()) + } +} + +// TestPreflightBackupSkipsFreshBook pins the fresh-book case: no DB yet means no signed bank to lose, so the +// guard is a silent no-op that creates no backups directory. +func TestPreflightBackupSkipsFreshBook(t *testing.T) { + bookPath := setupCLIProject(t, "http://127.0.0.1:1") + if err := preflightBackup(bookPath, io.Discard); err != nil { + t.Fatalf("a fresh book (no DB) pre-flight must be a no-op, got %v", err) + } + if _, err := os.Stat(filepath.Join(filepath.Dir(bookPath), "backups")); err == nil { + t.Error("a fresh book must create no backups directory") + } +} + +// TestFakeTranslatePathCreatesNoBackup is the discriminator proof: the fake-provider harness drives +// translate() DIRECTLY (as the golden/rebill tests do), which never enters run()'s boevoy dispatch — so it +// must create NO backup. This is what lets the guard avoid a provider-name magic string (generality §0). +func TestFakeTranslatePathCreatesNoBackup(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = io.ReadAll(r.Body) + tb, _ := json.Marshal("Тихое утро в библиотеке.") + fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}`, tb) + })) + defer srv.Close() + + bookPath := setupCLIProject(t, srv.URL) + if err := translate(context.Background(), bookPath, false, pipeline.RebillConsent{}, false); err != nil { + t.Fatalf("fake translate: %v", err) + } + if _, err := os.Stat(filepath.Join(filepath.Dir(bookPath), "backups")); err == nil { + t.Error("translate() driven directly (the fake path) must NOT create backups — the pre-flight is in run() only") + } +} diff --git a/backend/cmd/tmctl/main.go b/backend/cmd/tmctl/main.go index 0a4c9691..aae375a1 100644 --- a/backend/cmd/tmctl/main.go +++ b/backend/cmd/tmctl/main.go @@ -1,4 +1,4 @@ -// tmctl is the TextMachine CLI: translate / report / status / redrive. +// tmctl is the TextMachine CLI: translate / report / status / redrive / backup. // main.go — thin wiring (package №4): argument parsing — invocation.go, // output renderers — render.go, .env — dotenv.go; here just the // «parse → env → ctx → fetch → render» wiring and exit-code mapping. @@ -71,6 +71,12 @@ func run() error { switch inv.cmd { case "translate": + // F4 (row 83): back up + integrity-check the project DB before a paid run starts. This lives HERE + // (the boevoy command dispatch), not in translate()/the Runner, so a fake-provider test that drives + // those directly never triggers it — no provider-name discriminator needed. + if err := preflightBackup(inv.cfgPath, os.Stdout); err != nil { + return err + } return translate(ctx, inv.cfgPath, inv.resnapshot, inv.acceptRebill, inv.verifyBank) case "report": return report(inv.cfgPath) @@ -79,11 +85,19 @@ func run() error { case "export": return export(inv.cfgPath, inv.asPlaintext, inv.asPairs) case "redrive": + // A dry-run redrive spends nothing and mutates nothing; only a real redrive re-attacks and re-bills. + if !inv.sel.DryRun { + if err := preflightBackup(inv.cfgPath, os.Stdout); err != nil { + return err + } + } return redrive(ctx, inv.cfgPath, inv.resnapshot, inv.acceptRebill, inv.sel, inv.verifyBank) + case "backup": + return backupCmd(inv.cfgPath, os.Stdout) case "seed-lint": return seedLint(inv.seedPath) default: - return fmt.Errorf("unknown command %q (want translate|report|status|export|redrive|seed-lint)", inv.cmd) + return fmt.Errorf("unknown command %q (want translate|report|status|export|redrive|backup|seed-lint)", inv.cmd) } } diff --git a/backend/cmd/tmctl/render.go b/backend/cmd/tmctl/render.go index 3b4c89cc..ce87f6bb 100644 --- a/backend/cmd/tmctl/render.go +++ b/backend/cmd/tmctl/render.go @@ -86,6 +86,9 @@ func renderSignatureStop(w io.Writer, s *pipeline.WaveSignatureStop) { fmt.Fprintln(w, "Next: for EACH term either promote it into the mined-delta file (approved + dst), OR decline it in") fmt.Fprintln(w, "the mined-rejects file (book.yaml: mined_rejects), then re-run `tmctl translate`. The stop clears") fmt.Fprintln(w, "once every proposed term is promoted or rejected (then the delta is empty → auto-continue to the edit wave).") + // The ⟨проверить⟩ mentions here and at :485 NAME the ru target's unverified marker for the OPERATOR — not a + // model-facing wire string (that canon is injection.txt / embedded.UnverifiedMarker). Bounded row-89 leak: a + // non-ru target would still print the ru glyph in this help text; not worth plumbing the per-target marker in. fmt.Fprintln(w, "To run WITHOUT this pause, drop --verify-bank: the run then carries the unsigned bank forward marked ⟨проверить⟩.") } diff --git a/backend/configs/langpacks/zh-ru/dc-checkers.txt b/backend/configs/langpacks/zh-ru/dc-checkers.txt index 7d2da986..37263363 100644 --- a/backend/configs/langpacks/zh-ru/dc-checkers.txt +++ b/backend/configs/langpacks/zh-ru/dc-checkers.txt @@ -8,6 +8,9 @@ cjk_numeral 一 1 cjk_numeral 二 2 cjk_numeral 两 2 cjk_numeral 兩 2 +# NB traditional-Chinese boundary (D39.79): the NUMERAL classes carry BOTH forms (两/兩=2 here, and in +# shichen_re and cheng_re) so a traditional-script count reads correctly. The pattern LITERALS (时辰, 千万, +# 数十万) stay simplified until the first traditional-Chinese book ships its own pack — this corpus has none. cjk_numeral 三 3 cjk_numeral 四 4 cjk_numeral 五 5 @@ -26,20 +29,16 @@ ru_hour трех 3 ru_hour четыре 4 ru_hour пять 5 ru_hour шесть 6 -# DC6 register negative-list: fairy-tale/chancery lexemes out of the xianxia register (терем + case forms). -register_neg терем -register_neg терема -register_neg тереме -register_neg теремом -register_neg терему -register_neg теремах +# DC6 register blocklist is NOT here (D39.79 Q4): the out-of-register lexis is a genre/BOOK property, so it +# lives in book.yaml `register_blocklist`, not this pair pack. register_neg stays an OPTIONAL pair category +# (a pair MAY ship target-register facts independent of any book), but zh-ru ships none. # --- DETECTION patterns (pair-14 data-out). `patternkeyvalue`; value VERBATIM. --- # DC1: count before 时辰 (src); Russian « час…» rendering (final). # ru_hours_re carries an explicit RIGHT boundary: Go RE2 has no lookahead and its \b is ASCII-only, so the # hour word is spelled out as its own paradigm and must be followed by a non-Cyrillic char or end-of-text. # Without it the bare stem «час» matched INSIDE other words — «три части» read as «три час…» (a live false # flag on ordinary prose, and a false ACTUATOR trigger once a repair loop acts on this class). -pattern shichen_re ([0-9一二两三四五六七八九十])\s*个?\s*时辰 +pattern shichen_re ([0-9一二两兩三四五六七八九十])\s*个?\s*时辰 pattern ru_hours_re (\d+|один|два|двух|три|трёх|трех|четыре|пять|шесть)\s+час(?:ами|ах|ам|ов|ом|а|у|е|ы)?(?:[^а-яёА-ЯЁ]|$) # DC1-half: 半个时辰 ≈ 1 час. The count group of shichen_re only accepts a NUMERAL, so 半 («half») — the single # most frequent 时辰 form in the corpus — is invisible to it. This is a separate src probe + a target fire word: @@ -78,5 +77,4 @@ msg dc1_fractional DC1 时辰 (fractional): 半个时辰 ≈ 1 h rendered as «{ msg dc1_counted DC1 时辰: {n}个时辰 rendered as «{rendered} час…» (counted as hours) instead of ~{expected} h (1 时辰 = {ratio} h) msg dc2_qianwan DC2 千万=10^7 rendered as «тысячи» (≈10000× under) — a possible magnitude error (hyperbole risk, §5-A4) msg dc2_shushiwan DC2 数十万≈several×10^5 rendered as «десятки тысяч» (≈10× under) -msg dc6_register DC6 register: fairy-tale Russian lexis out of the xianxia genre: {hits} msg percent_scale 成-percent: {tens}成{ones} = {pct}% rendered as a decimal fraction instead of a percentage (~{pct}%) diff --git a/backend/example/book.yaml b/backend/example/book.yaml index b50b3412..c11b35d9 100644 --- a/backend/example/book.yaml +++ b/backend/example/book.yaml @@ -11,6 +11,11 @@ venuti: 0.6 honorifics: keep transcription: palladius # zh→ru: система Палладия (пиньинь — латинская, не для ru-таргета) footnotes: minimal +# DC6 register_blocklist (D39.79 Q4): out-of-register target lexis to FLAG for THIS book — a genre/book +# property, NOT a pair fact, so a book of another genre ships its own (or none → DC6 runs inert). Whole-word, +# case-insensitive; verdict-affecting → folded into brief_hash. Example for a xianxia book (fairy-tale/chancery +# words wrong for the register), left commented because this sample fragment does not need one: +# register_blocklist: [терем, терема, тереме, теремом, терему, теремах] pipeline: ../configs/pipeline-c1.yaml models: ../configs/models.yaml diff --git a/backend/internal/checks/cheapgates.go b/backend/internal/checks/cheapgates.go index fa06e155..bf15a400 100644 --- a/backend/internal/checks/cheapgates.go +++ b/backend/internal/checks/cheapgates.go @@ -113,6 +113,10 @@ type CheapGateConfig struct { // resolved once per run. nil for a bare config or a book with no data → the language-specific checkers // run inert (fire 0, the no-pack golden path); the general Latin-residue check needs no spec. Checkers *Checkers + // RegisterBlocklist is the book's DC6 out-of-register target lexis (book.yaml register_blocklist, D39.79 + // Q4): genre-wrong fairy-tale/chancery words for THIS book. It lives with the book, not the pair pack, so + // a second zh→ru book of another genre ships its own (or none, running DC6 inert). Lower-cased on load. + RegisterBlocklist []string } // CheapGateResult is the per-chunk outcome: a count per flagger plus human-readable detail lines @@ -177,7 +181,7 @@ func RunCheapGates(source, draft, final string, cfg CheapGateConfig) CheapGateRe n, det = cfg.Checkers.lintMagnitudeScale(source, final) r.DC2Magnitude = n r.Detail = append(r.Detail, det...) - n, det = cfg.Checkers.lintRegisterLexicon(final) + n, det = cfg.Checkers.lintRegisterLexicon(final, cfg.RegisterBlocklist) r.DC6Register = n r.Detail = append(r.Detail, det...) // pack-13 general checkers (checkers.go): percent scale (pair data), Latin residue (language-general), @@ -278,8 +282,9 @@ func (c *Checkers) lineHasInnerMarker(line string) bool { } // containsWholeWordPhrase reports whether phrase occurs in low bounded by a non-word rune (or an edge) on -// both OUTER ends; a multi-word marker's internal spaces match literally. Mirrors the whole-word rule the -// interjection/register checks use (isWordRune boundary), so «в уме» is not found inside «в умении». +// both OUTER ends; a multi-word marker's internal spaces match literally. Mirrors the interjection check's +// whole-word rule (isWordRune boundary; the register check uses the target's isTargetWordLetter, same effect +// for Cyrillic), so «в уме» is not found inside «в умении». func containsWholeWordPhrase(low, phrase []rune) bool { n := len(phrase) if n == 0 || n > len(low) { @@ -304,6 +309,12 @@ func containsWholeWordPhrase(low, phrase []rune) bool { // своё время…» yields «вздохнул про себя Фан Юань», and the inner-marker test sees only the attribution // itself — closing the k4_inverse false positives (a spoken line that merely SAYS «про себя»; the homograph // «про себя»=«о себе» in a trailing clause; a narration tail). "" when the line carries no inline attribution. +// +// RECALL CEILING of the rule, in the contract (mirror of #3's Р4 note, not a data gap): cutting at the NEXT +// mark also hides a marker that sits AFTER an internal comma/ellipsis WITHIN one attribution — «— …гу, — +// сказал он, про себя ругаясь» yields «сказал он», so the inner «про себя» is invisible and the inverse +// thought stays unflagged. That FN is a ceiling of the two-sided cut itself, not of the corpus; widening the +// segment to recover it would re-open the trailing-clause false positives the cut exists to close. func speechAttribution(line string) string { rs := []rune(line) for k := 0; k+1 < len(rs); k++ { @@ -376,11 +387,14 @@ func chevronSpeechShape(rs []rune) bool { } // isInlineSpace reports whether r is an inline space (ASCII space or NBSP) — the whitespace the dialogue -// shapes skip around a marker. +// shapes skip around a marker. Thin space U+2009 is deliberately NOT included (an inherited HEAD convention, +// unchanged per row 93): a marker set off by a thin space would not be skipped — a known, unmeasured limitation. func isInlineSpace(r rune) bool { return r == ' ' || r == '\u00a0' } -// sentenceFinalBefore reports whether the rune just before the closing » at rs[i] is a sentence-final mark -// (! ? … or an ASCII "..."), the signature of an exclamatory/interrogative reply vs a flat citation. +// sentenceFinalBefore reports whether the rune just before the closing » at rs[i] is a sentence-final mark: +// ! ? … or an ASCII ellipsis of TWO+ dots — only the last two are inspected, so «..» and «...» both pass (the +// code and this comment reconciled to two, row 93; the «..»-behaviour is frozen by the label baseline). It is +// the signature of an exclamatory/interrogative/trailing-off reply, as opposed to a flat citation. func sentenceFinalBefore(rs []rune, i int) bool { if i == 0 { return false diff --git a/backend/internal/checks/checkers.go b/backend/internal/checks/checkers.go index cce77643..d73de19f 100644 --- a/backend/internal/checks/checkers.go +++ b/backend/internal/checks/checkers.go @@ -20,9 +20,10 @@ import ( // // PAIR-AGNOSTIC (pair-14 data-out): this file no longer holds any language literal. Every DETECTION pattern, // lookup table and wordlist is DATA. The PAIR checkers read the pair pack configs/langpacks//dc-checkers.txt -// (lang.DCCheckerData): the src↔tgt ones (DC1/DC2/percent — they compare a source token to the rendering) AND -// the register negative-list (DC6 — register_neg is a pair table, its lexemes are a target-in-this-pair concern -// stored with the pair). The TARGET-general checker (broken word) runs on ANY →target output and reads the +// (lang.DCCheckerData): the src↔tgt ones (DC1/DC2/percent — they compare a source token to the rendering). The +// DC6 register blocklist is NOT here: it is a genre/BOOK property (book.yaml register_blocklist → CheapGateConfig, +// D39.79 Q4), so a second zh→ru book of another genre ships its own without a Go or pair edit. The TARGET-general +// checker (broken word) runs on ANY →target output and reads the // embedded target data (lang.TargetChecks "broken_suffix"). The ALGORITHM (compare counts, ×2 hours, // suppress-if-ok, whole-word match) stays here. A pair/target that ships no data runs the relevant sub-checker // inert (empty → 0, the no-pack golden path). Version rides the langpack Version() (data) + CheapGateVersion @@ -200,8 +201,10 @@ func CompileCheckers(dc *lang.DCCheckerData, tc lang.TargetChecks) *Checkers { c.halfShichenFireWord = p["halfshichen_fire_word"] c.hourWordRE = mustPairRE(p, "hour_word_re") c.dc1UnitHours = mustPairRatio(dc.Ratios, "dc1_unit_in_hours") + // dc6_register is NOT required (D39.79 Q4): the DC6 detail is a generic Go diagnostic now and the + // register blocklist moved to the book config, so a pair pack no longer ships a genre-bearing message. c.msg = mustPairMessages(dc.Messages, - "dc1_fractional", "dc1_counted", "dc2_qianwan", "dc2_shushiwan", "dc6_register", "percent_scale") + "dc1_fractional", "dc1_counted", "dc2_qianwan", "dc2_shushiwan", "percent_scale") } return c } @@ -440,26 +443,40 @@ func (c *Checkers) lintMagnitudeScale(source, final string) (int, []string) { // --- DC-6: register-lexicon negative-list (ws5.register_checker) ---------------------------------- -// lintRegisterLexicon flags whole-word occurrences of a register negative-list lexeme in the FINAL text. -// The negative-list is pair langpack DATA (DCCheckerData.RegisterNeg): fairy-tale-Russian / chancery lexemes -// that break the xianxia register. Lower-cased; whole-word matched. Empty (a no-pack book) → nothing to flag. -func (c *Checkers) lintRegisterLexicon(final string) (int, []string) { - if c == nil || len(c.registerNeg) == 0 { +// lintRegisterLexicon flags whole-word occurrences of an out-of-register lexeme in the FINAL text. The +// blocklist is the union of two sources, so the ALGORITHM stays generic while the DATA is where it belongs: +// - bookBlocklist — the BOOK's register_blocklist (book.yaml → CheapGateConfig, D39.79 Q4): a genre/book +// property («терем»…), NOT a pair fact, so a second zh→ru book of another genre ships its own or none; +// - c.registerNeg — an OPTIONAL pair-level register list (the zh-ru pack ships none after Q4; kept so a pair +// that ever has a target-register fact independent of any book can still express it in data). +// +// Both are lower-cased (book on load, pair by pack convention) and whole-word matched against the target's +// word-letter set. Empty union → inert (the «inert without data» contract). The detail is a generic English +// diagnostic in Go (like the other gates) — the pair pack no longer carries a genre-bearing message. +func (c *Checkers) lintRegisterLexicon(final string, bookBlocklist []string) (int, []string) { + if c == nil || (len(c.registerNeg) == 0 && len(bookBlocklist) == 0) { return 0, nil } low := []rune(strings.ToLower(final)) hitSet := map[string]bool{} - for _, w := range c.registerNeg { - wr := []rune(w) - for i := 0; i+len(wr) <= len(low); i++ { - if !text.RunesEqual(low[i:i+len(wr)], wr) { + scan := func(words []string) { + for _, w := range words { + wr := []rune(w) + if len(wr) == 0 { continue } - if (i == 0 || !c.isTargetWordLetter(low[i-1])) && (i+len(wr) == len(low) || !c.isTargetWordLetter(low[i+len(wr)])) { - hitSet[w] = true + for i := 0; i+len(wr) <= len(low); i++ { + if !text.RunesEqual(low[i:i+len(wr)], wr) { + continue + } + if (i == 0 || !c.isTargetWordLetter(low[i-1])) && (i+len(wr) == len(low) || !c.isTargetWordLetter(low[i+len(wr)])) { + hitSet[w] = true + } } } } + scan(c.registerNeg) + scan(bookBlocklist) if len(hitSet) == 0 { return 0, nil } @@ -468,7 +485,9 @@ func (c *Checkers) lintRegisterLexicon(final string) (int, []string) { hits = append(hits, w) } sort.Strings(hits) - return len(hits), []string{renderMsg(c.msg["dc6_register"], "hits", strings.Join(hits, ", "))} + // Source-neutral wording: a hit may come from EITHER the book register_blocklist OR the optional pair-level + // register_neg (the union above), so the detail must not name one config knob for a hit from the other. + return len(hits), []string{"register: out-of-register target lexis flagged: " + strings.Join(hits, ", ")} } // isTargetWordLetter reports whether r is a letter of the TARGET's declared word script — the register-match @@ -639,12 +658,14 @@ func (c *Checkers) isSpokenChevronLine(line string) bool { if c == nil || len(c.speechVerb) == 0 { return false } - low := strings.ToLower(line) - for _, marker := range c.innerMarker { - if strings.Contains(low, marker) { - return false // «пробормотал ПРО СЕБЯ» — a speaking verb qualified into a thought - } + // The inner-marker veto matches WHOLE-WORD (lineHasInnerMarker → containsWholeWordPhrase), so «в уме» does + // NOT muffle a real clash inside «в умении» — the same substring FN the dash-side veto already avoids (row + // 93). The verb probe below stays substring (its whole-line-citation residual is the separate DEFERRED gate + // above). Corpus has 0 instances of the «в умении» shape, so labels are unmoved: a boundary fix, not a metric one. + if c.lineHasInnerMarker(line) { + return false // «пробормотал ПРО СЕБЯ» — a speaking verb qualified into a thought } + low := strings.ToLower(line) for _, verb := range c.speechVerb { if strings.Contains(low, verb) { return true diff --git a/backend/internal/checks/checkers_pairdata_test.go b/backend/internal/checks/checkers_pairdata_test.go index 2ca77aeb..810dd48c 100644 --- a/backend/internal/checks/checkers_pairdata_test.go +++ b/backend/internal/checks/checkers_pairdata_test.go @@ -46,8 +46,9 @@ func synthPack(hours int) *lang.DCCheckerData { "dc1_counted": "SYNTH-COUNTED n={n} rendered={rendered} expected={expected} ratio={ratio}", "dc2_qianwan": "SYNTH-QIANWAN", "dc2_shushiwan": "SYNTH-SHUSHIWAN", - "dc6_register": "SYNTH-REGISTER {hits}", "percent_scale": "SYNTH-PERCENT {tens}/{ones}/{pct}", + // dc6_register is intentionally ABSENT (D39.79 Q4): the DC6 detail is a generic Go string now, so a + // pack need not — and no longer may be required to — ship a register message. }, } } @@ -91,8 +92,9 @@ func TestCheckerDetailTextComesFromPairData(t *testing.T) { func() (int, []string) { return c.lintMagnitudeScale("有数十万人。", "Там были десятки тысяч人.") }}, - {"dc6-register", "SYNTH-REGISTER терем", - func() (int, []string) { return c.lintRegisterLexicon("Он вошёл в терем.") }}, + // dc6-register is NOT here: after Q4 its detail is a generic Go string, not the pack's sentence — it is + // deliberately no longer a pair-data-messaged checker. The optional pair register path is covered by + // TestDC6PairLevelRegisterOptional and the book path by TestDC6RegisterLexicon. {"percent", "SYNTH-PERCENT 三//30", func() (int, []string) { return c.lintPercentScale("他用了三成力。", "Он использовал три десятых силы.") @@ -121,7 +123,7 @@ func TestCorruptPairPackFailsLoud(t *testing.T) { }{ {"no ratio", "dc1_unit_in_hours", func(d *lang.DCCheckerData) { delete(d.Ratios, "dc1_unit_in_hours") }}, {"zero ratio", "dc1_unit_in_hours", func(d *lang.DCCheckerData) { d.Ratios["dc1_unit_in_hours"] = 0 }}, - {"no message", "dc6_register", func(d *lang.DCCheckerData) { delete(d.Messages, "dc6_register") }}, + {"no message", "dc2_shushiwan", func(d *lang.DCCheckerData) { delete(d.Messages, "dc2_shushiwan") }}, {"blank message", "dc1_counted", func(d *lang.DCCheckerData) { d.Messages["dc1_counted"] = " " }}, } { t.Run(tc.name, func(t *testing.T) { @@ -149,7 +151,21 @@ func TestNoPackStaysInert(t *testing.T) { if n, _ := c.lintTimeUnits("他闭关了三个时辰。", "Он затворился на три часа."); n != 0 { t.Fatalf("a book with no pair pack must run the pair checkers inert, got %d", n) } - if n, _ := c.lintRegisterLexicon("Он вошёл в терем."); n != 0 { - t.Fatalf("register check must be inert with no pack, got %d", n) + if n, _ := c.lintRegisterLexicon("Он вошёл в терем.", nil); n != 0 { + t.Fatalf("register check must be inert with no pack and no book blocklist, got %d", n) + } +} + +// TestDC6PairLevelRegisterOptional pins the OTHER union member (D39.79 Q4): a pack MAY still ship an optional +// pair-level register list, and it fires via lintRegisterLexicon even with no book blocklist — the generic +// Go detail replaces the pack's old dc6_register message. +func TestDC6PairLevelRegisterOptional(t *testing.T) { + c := CompileCheckers(synthPack(2), lang.TargetChecks{}) // synthPack ships RegisterNeg: ["терем"] + n, det := c.lintRegisterLexicon("Он вошёл в терем.", nil) + if n != 1 { + t.Fatalf("an optional pair-level register lexeme must still fire, got %d", n) + } + if len(det) == 0 || !strings.Contains(det[0], "терем") { + t.Fatalf("the generic detail must name the hit, got %q", det) } } diff --git a/backend/internal/checks/checkers_zh_ru_test.go b/backend/internal/checks/checkers_zh_ru_test.go index 24e2b3af..26ac1b9a 100644 --- a/backend/internal/checks/checkers_zh_ru_test.go +++ b/backend/internal/checks/checkers_zh_ru_test.go @@ -80,6 +80,10 @@ func TestDC2MagnitudeScale(t *testing.T) { } func TestDC6RegisterLexicon(t *testing.T) { + // The register blocklist now comes from the BOOK config (book.yaml register_blocklist → CheapGateConfig, + // D39.79 Q4), not the pair pack. These fixtures exercise the checker ALGORITHM (whole-word, target + // word-boundary) over a book-supplied blocklist — the terem paradigm that used to live in dc-checkers.txt. + blocklist := []string{"терем", "терема", "тереме", "теремом", "терему", "теремах"} cases := []struct { name, tgt string wantN int @@ -92,12 +96,18 @@ func TestDC6RegisterLexicon(t *testing.T) { dcc := testCheckers(t) for _, c := range cases { t.Run(c.name, func(t *testing.T) { - n, det := dcc.lintRegisterLexicon(c.tgt) + n, det := dcc.lintRegisterLexicon(c.tgt, blocklist) if n != c.wantN { t.Fatalf("lintRegisterLexicon(%q) = %d (%v), want %d", c.tgt, n, det, c.wantN) } }) } + // The D39.79 second-book generality invariant: the real zh-ru pack ships NO register_neg, so a book that + // supplies no register_blocklist runs DC6 inert — a zh→ru book of another genre gets ZERO false DC6 flags + // and needs NO Go/pair edit. + if n, _ := dcc.lintRegisterLexicon("Он вошёл в высокий терем.", nil); n != 0 { + t.Fatalf("with an empty pair register_neg and no book register_blocklist DC6 must be inert, got %d", n) + } } // The DC checkers must stay SILENT on a non-zh / clean chunk (they self-gate on content), so a @@ -169,3 +179,30 @@ func TestDC1FractionalProbeInertWithoutData(t *testing.T) { t.Fatalf("fractional probe must be inert without pair data, fired %d", n) } } + +// TestChevronMixingWholeWordInnerMarker pins the row-93 whole-word fix on isSpokenChevronLine's inner-marker +// veto. «в уме» is a substring of «в умении»; a substring veto would MUFFLE a real style clash — a chevron +// reply whose text merely CONTAINS «в умении», mixed with an em-dash line in the same chunk. The veto must +// match whole-word, so the clash fires; a genuine whole-word marker («про себя») must still veto. The +// labelled corpus carries 0 instances of the «в умении» shape, so this moves no label cell — a boundary fix. +func TestChevronMixingWholeWordInnerMarker(t *testing.T) { + dcc := testCheckers(t) + const emDashLine = "— Здравствуй, брат.\n" // an em-dash speech line → the mixing pre-condition + cases := []struct { + name, chevron string + wantMixing bool + }{ + // «в умении» contains the substring «в уме» but is not the marker — the style clash must fire. + {"substring-not-marker-fires", "«Замолчи!» — воскликнул он, упражняясь в умении.", true}, + // «про себя» is a whole-word inner marker — the reply is a thought, so the veto still muffles. + {"whole-word-marker-vetoes", "«Замолчи!» — пробормотал он про себя.", false}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + n, det := lintDialogueDash(emDashLine+c.chevron, dcc) + if (n > 0) != c.wantMixing { + t.Fatalf("lintDialogueDash mixing fired=%v (%v), want %v", n > 0, det, c.wantMixing) + } + }) + } +} diff --git a/backend/internal/checks/labelcandidates_test.go b/backend/internal/checks/labelcandidates_test.go index 565a499c..5b4ca8d5 100644 --- a/backend/internal/checks/labelcandidates_test.go +++ b/backend/internal/checks/labelcandidates_test.go @@ -62,10 +62,16 @@ func TestCheckerLabelsCandidates(t *testing.T) { t.Logf("#6 latin — CAPS-AWARE : %+v prec=%s recall=%s", k2Caps, k2Caps.precision(), k2Caps.recall()) t.Logf("#7 latin — THRESHOLD=2 : %+v prec=%s recall=%s", k2Thr2, k2Thr2.precision(), k2Thr2.recall()) - // --- k4_inverse (D39.64 §5.5 inner_marker-veto-on-dash): a dash-led line that carries an inner-speech - // marker («про себя», «себе под нос») is a THOUGHT typeset as spoken dialogue. Candidate: flag such a - // dash line. Measures recall on the 4 labelled dash-thoughts and precision against spoken dash lines. - invBase, invCand := metric{}, metric{} + // --- k4_inverse (D39.64 §5.5 inner_marker-veto-on-dash): a dash-led line whose ATTRIBUTION carries an + // inner-speech marker («про себя», «себе под нос») is a THOUGHT typeset as spoken dialogue. BASE = no rule + // (a dash thought is structurally invisible). CAND = the SHIPPED production rule: the marker must sit in the + // attribution segment (speechAttribution + lineHasInnerMarker, whole-word) — the same predicate measureK4 + // runs. REJECTED = the once-proposed whole-line-substring variant, refuted by adversarial review (a spoken + // reply that merely SAYS «про себя» tripped it). REJECTED scores IDENTICALLY to CAND on this corpus (3/0/1/71): + // the labelled set carries none of the divergence, which is exactly why the FP took an adversarial reader, not + // a label count, to find — so it is kept, explicitly labelled, never presented as the candidate (mirror of + // chevronSpeechShapeCommaOnly's discipline: a refuted rule must not read as the prod number). + invBase, invCand, invRej := metric{}, metric{}, metric{} for _, l := range loadJSONL(t, filepath.Join(labelsDir, "labels", "k4.jsonl")) { if l.Bucket != "em-dash" { continue @@ -73,12 +79,16 @@ func TestCheckerLabelsCandidates(t *testing.T) { line := strings.TrimLeft(lineForLabel(l, units), " \t  ") rs := []rune(line) isDash := len(rs) > 0 && (rs[0] == '—' || rs[0] == '–' || rs[0] == '-') - cand := isDash && hasInnerMarker(c, line) + attr := speechAttribution(line) + shipped := isDash && attr != "" && c.lineHasInnerMarker(attr) + rejected := isDash && hasInnerMarkerWholeLine(c, line) joinK4(&invBase, l.Speech, "thought", false) - joinK4(&invCand, l.Speech, "thought", cand) + joinK4(&invCand, l.Speech, "thought", shipped) + joinK4(&invRej, l.Speech, "thought", rejected) } - t.Logf("k4_inverse — BASE (no rule): %+v recall=%s", invBase, invBase.recall()) - t.Logf("k4_inverse — CAND (dash+inner_marker): %+v prec=%s recall=%s", invCand, invCand.precision(), invCand.recall()) + t.Logf("k4_inverse — BASE (no rule) : %+v recall=%s", invBase, invBase.recall()) + t.Logf("k4_inverse — CAND (shipped: dash + inner-marker in attribution) : %+v prec=%s recall=%s", invCand, invCand.precision(), invCand.recall()) + t.Logf("k4_inverse — REJECTED (whole-line substring; refuted by review) : %+v prec=%s recall=%s", invRej, invRej.precision(), invRej.recall()) // --- #6 detail: which labelled tokens are caps-shaped, and their label (precision risk audit). var capsDefect, capsOK []string @@ -179,8 +189,10 @@ func latinHit(text, token string, rule latinRule) bool { return false } -// hasInnerMarker reports whether the line carries one of the target's inner-speech markers (target data). -func hasInnerMarker(c *Checkers, line string) bool { +// hasInnerMarkerWholeLine reports whether the line carries an inner-speech marker ANYWHERE on it, as a bare +// SUBSTRING — the REFUTED k4_inverse variant, kept only to measure it against the shipped attribution-scoped +// rule (it must never masquerade as the candidate). Production uses speechAttribution + lineHasInnerMarker. +func hasInnerMarkerWholeLine(c *Checkers, line string) bool { low := strings.ToLower(line) for _, m := range c.innerMarker { if strings.Contains(low, m) { diff --git a/backend/internal/checks/repair.go b/backend/internal/checks/repair.go index 4a520772..46a4e5c0 100644 --- a/backend/internal/checks/repair.go +++ b/backend/internal/checks/repair.go @@ -346,8 +346,9 @@ func isSpaceByte(b byte) bool { return b == ' ' || b == '\t' || b == '\n' || b = // exactly the predicates the driver's guard needs, over the SAME compiled pair/target data the detectors // use, so a new pair inherits them without touching Go. -// LatinResidueCount counts leaked lowercase Latin tokens (allowlist-free — the guard compares a span with -// its replacement, and an allow-listed surface is equally allowed on both sides). +// LatinResidueCount counts leaked Latin tokens of ANY case — after defect #6 the caps-reject is gone, so BANK +// and Cultivation count alongside lowercase ones (allowlist-free — the guard compares a span with its +// replacement, and an allow-listed surface is equally allowed on both sides). func LatinResidueCount(s string) int { n, _ := lintLatinResidue(s, nil) return n diff --git a/backend/internal/chunk/chunker.go b/backend/internal/chunk/chunker.go index 3b2d6486..552d3625 100644 --- a/backend/internal/chunk/chunker.go +++ b/backend/internal/chunk/chunker.go @@ -230,14 +230,16 @@ func isHeadingNumeral(r rune) bool { } // isHeaderContentRune reports whether a rune is CONTENT (a letter/digit/ideograph/kana) rather than a -// separator — the guard that keeps a glued measure word from reading as a header (ingest parity). +// separator — the guard that keeps a glued measure word from reading as a header (ingest parity). It is the +// single source of truth for the content/separator split: ingest.isHeaderSeparator is its exact complement. func isHeaderContentRune(r rune) bool { return unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) } // isHeadingSeparator reports whether a rune separates the marker from the subtitle (trimmed off the head -// of the subtitle): CJK/ASCII colons, commas, periods, dashes, the ideographic space and middle dots. +// of the subtitle): CJK/ASCII colons, commas, periods, dashes, the ideographic space and middle dots. This +// is a DISTINCT, narrower whitelist — NOT the complement of isHeaderContentRune (that is isHeaderSeparator). func isHeadingSeparator(r rune) bool { switch r { case ':', ':', '、', ',', ',', '.', '。', '-', '—', '–', ' ', '\t', ' ', '·', '・': diff --git a/backend/internal/chunk/ingest.go b/backend/internal/chunk/ingest.go index f694d7ba..802aa678 100644 --- a/backend/internal/chunk/ingest.go +++ b/backend/internal/chunk/ingest.go @@ -171,13 +171,12 @@ func isCJKChapterHeader(line string, unit rune) bool { // isHeaderSeparator reports whether a rune separates a chapter number from its title (so the line is // a header, not a prose sentence that continues with a content glyph after 第N章). Anything that is -// NOT a letter / ideograph / kana / digit counts as a separator (space, ::、,,.。-—— etc.). +// NOT content (letter / ideograph / kana / digit) counts as a separator. It is the exact complement of +// chunker.go's isHeaderContentRune — one shared rune-class list, so the two cannot byte-drift apart (row +// 89: previously the same classification was spelled out in both files, synced only by hand). NOT to be +// confused with chunker.go's isHeadingSeparator, a narrower punctuation whitelist for trimming subtitles. func isHeaderSeparator(r rune) bool { - if unicode.IsLetter(r) || unicode.IsDigit(r) || - unicode.Is(unicode.Han, r) || unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r) { - return false - } - return true + return !isHeaderContentRune(r) } // detectChapterUnit picks the section marker that appears most as a header line (≥2 to avoid a diff --git a/backend/internal/config/book.go b/backend/internal/config/book.go index 4c3c48e4..944a6082 100644 --- a/backend/internal/config/book.go +++ b/backend/internal/config/book.go @@ -57,6 +57,12 @@ type Book struct { // allowlist, 04-unhappy §6): a character actually named «Ара» is not an untranslated filler. // Lower-cased on load. Verdict-affecting → also folded into BriefHash. StyleAllowlist []string `yaml:"style_allowlist"` + // RegisterBlocklist is the DC6 out-of-register target lexis for THIS book (D39.79 Q4): fairy-tale / + // chancery Russian words wrong for the book's genre («терем»…). It lives with the BOOK, not the pair + // pack, because it is a genre/book property — a different zh→ru book of another genre ships its own (or + // none). Lower-cased on load; verdict-affecting → folded into BriefHash (omitempty keeps a book without + // one byte-identical). Empty = the DC6 register checker runs inert («inert without data»). + RegisterBlocklist []string `yaml:"register_blocklist"` // Wiring: paths are resolved relative to the book.yaml location. Pipeline string `yaml:"pipeline"` @@ -236,6 +242,11 @@ func LoadBook(path string) (*Book, error) { // → the snapshot → force an unnecessary re-pin (self-review: it is a set, consumed order- // independently in cheapGateConfig). sort.Strings(b.StyleAllowlist) + // Same discipline for the register blocklist (D39.79 Q4): lower-fold + sort so a reorder is BriefHash-neutral. + for i, s := range b.RegisterBlocklist { + b.RegisterBlocklist[i] = strings.ToLower(strings.TrimSpace(s)) + } + sort.Strings(b.RegisterBlocklist) if b.Ceilings.BookUSD <= 0 && b.Ceilings.DayUSD <= 0 { bad("ceilings: at least one of book_usd/day_usd must be set (a ledger with no ceiling is forbidden, Р7)") } @@ -280,7 +291,10 @@ func (b *Book) BriefHash() string { Footnotes string `json:"footnotes"` YoPolicy string `json:"yo_policy"` StyleAllowlist []string `json:"style_allowlist"` - }{b.BookID, b.Title, b.SourceLang, b.TargetLang, b.Genre, b.Audience, retiredContentSlot, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes, b.YoPolicy, b.StyleAllowlist} + // RegisterBlocklist is verdict-affecting (DC6), so a change must re-pin. omitempty keeps a book WITHOUT + // one (the golden fixture, every pre-D39.79 book) byte-identical → the FROZEN LAYOUT above is preserved. + RegisterBlocklist []string `json:"register_blocklist,omitempty"` + }{b.BookID, b.Title, b.SourceLang, b.TargetLang, b.Genre, b.Audience, retiredContentSlot, b.Venuti, b.Honorifics, b.Transcription, b.Footnotes, b.YoPolicy, b.StyleAllowlist, b.RegisterBlocklist} data, err := json.Marshal(canon) if err != nil { // A struct of scalars cannot fail to marshal; keep the signature clean. diff --git a/backend/internal/lang/langpack.go b/backend/internal/lang/langpack.go index da2b4b55..a11821cf 100644 --- a/backend/internal/lang/langpack.go +++ b/backend/internal/lang/langpack.go @@ -886,6 +886,17 @@ func unionKeysAsSet(dst map[string]bool, src map[string]string) { // // Scans raw lines so an error names the PHYSICAL file line. Fail-loud on a bad field count / non-integer // value / unknown category (a malformed table is a corrupt pack). RegisterNeg keeps its authored order. +// putUniqueDC assigns m[k]=v but refuses a duplicate key WITHIN one dc-checkers file: a repeated key is a typo +// whose second row would silently shadow the first (a lost numeral/pattern/message/ratio), so it fails loud at +// load — before any billing — the intra-file mirror of unionStringMap's cross-file collision refusal (PACK15). +func putUniqueDC[K comparable, V any](m map[K]V, k K, v V, category string, line int) error { + if _, dup := m[k]; dup { + return fmt.Errorf("line %d: duplicate %s key %v (a repeated key silently shadows the earlier row)", line, category, k) + } + m[k] = v + return nil +} + func parseDCCheckers(b []byte) (*DCCheckerData, error) { d := &DCCheckerData{ Numeral: map[rune]int{}, RuHours: map[string]int{}, @@ -906,9 +917,13 @@ func parseDCCheckers(b []byte) (*DCCheckerData, error) { return nil, fmt.Errorf("line %d: %s wants `%skeyvalue`, got %q", i+1, f[0], f[0], line) } if f[0] == "msg" { - d.Messages[f[1]] = f[2] + if err := putUniqueDC(d.Messages, f[1], f[2], "msg", i+1); err != nil { + return nil, err + } } else { - d.Patterns[f[1]] = f[2] + if err := putUniqueDC(d.Patterns, f[1], f[2], "pattern", i+1); err != nil { + return nil, err + } } continue } @@ -926,7 +941,9 @@ func parseDCCheckers(b []byte) (*DCCheckerData, error) { if err != nil { return nil, fmt.Errorf("line %d: cjk_numeral value %q: %w", i+1, f[2], err) } - d.Numeral[r[0]] = v + if err := putUniqueDC(d.Numeral, r[0], v, "cjk_numeral", i+1); err != nil { + return nil, err + } case "ru_hour": if len(f) != 3 { return nil, fmt.Errorf("line %d: ru_hour wants `ru_hourwordvalue`, got %q", i+1, t) @@ -935,7 +952,9 @@ func parseDCCheckers(b []byte) (*DCCheckerData, error) { if err != nil { return nil, fmt.Errorf("line %d: ru_hour value %q: %w", i+1, f[2], err) } - d.RuHours[f[1]] = v + if err := putUniqueDC(d.RuHours, f[1], v, "ru_hour", i+1); err != nil { + return nil, err + } case "register_neg": if len(f) != 2 || strings.TrimSpace(f[1]) == "" { return nil, fmt.Errorf("line %d: register_neg wants `register_neglexeme`, got %q", i+1, t) @@ -949,13 +968,17 @@ func parseDCCheckers(b []byte) (*DCCheckerData, error) { if err != nil { return nil, fmt.Errorf("line %d: dc_ratio value %q: %w", i+1, f[2], err) } - d.Ratios[f[1]] = v + if err := putUniqueDC(d.Ratios, f[1], v, "dc_ratio", i+1); err != nil { + return nil, err + } default: return nil, fmt.Errorf("line %d: unknown category %q (want cjk_numeral|ru_hour|register_neg|dc_ratio|pattern|msg)", i+1, f[0]) } } - if len(d.Numeral) == 0 || len(d.RuHours) == 0 || len(d.RegisterNeg) == 0 { - return nil, fmt.Errorf("dc-checkers needs non-empty cjk_numeral, ru_hour and register_neg sections") + // register_neg is OPTIONAL (D39.79 Q4): the DC6 register blocklist moved to the book config + // (book.yaml register_blocklist), so a pair file with no register table is legal. + if len(d.Numeral) == 0 || len(d.RuHours) == 0 { + return nil, fmt.Errorf("dc-checkers needs non-empty cjk_numeral and ru_hour sections") } return d, nil } diff --git a/backend/internal/lang/langpack_test.go b/backend/internal/lang/langpack_test.go index cb5a9b0a..d748fd66 100644 --- a/backend/internal/lang/langpack_test.go +++ b/backend/internal/lang/langpack_test.go @@ -583,3 +583,37 @@ func copyPackDir(t *testing.T, from, to string) { } } } + +// TestParseDCCheckersRefusesIntraFileDupKey pins the row-53 fix: a repeated key WITHIN one dc-checkers file +// fails loud at load — it would otherwise silently shadow the earlier row (a lost numeral/pattern/message/ +// ratio). The intra-file mirror of unionStringMap's cross-file collision refusal (PACK15 §Внутрифайловый). +func TestParseDCCheckersRefusesIntraFileDupKey(t *testing.T) { + // A minimal blob satisfying the non-empty requirement (cjk_numeral + ru_hour; register_neg is OPTIONAL + // after D39.79 Q4 but kept here to exercise the list-tolerance case below). + base := "cjk_numeral\t一\t1\nru_hour\tчас\t1\nregister_neg\tтерем\n" + if _, err := parseDCCheckers([]byte(base)); err != nil { + t.Fatalf("the base blob must parse clean, got %v", err) + } + cases := []struct{ name, blob string }{ + {"cjk_numeral", base + "cjk_numeral\t一\t1\n"}, // 一 repeated + {"ru_hour", base + "ru_hour\tчас\t1\n"}, // час repeated + {"dc_ratio", base + "dc_ratio\tr\t1\ndc_ratio\tr\t2\n"}, // r repeated → second would shadow + {"pattern", base + "pattern\tp\ta\npattern\tp\tb\n"}, // p repeated + {"msg", base + "msg\tm\ta\nmsg\tm\tb\n"}, // m repeated + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + _, err := parseDCCheckers([]byte(c.blob)) + if err == nil { + t.Fatalf("a duplicate %s key must fail loud, got nil error", c.name) + } + if !strings.Contains(err.Error(), "duplicate") { + t.Fatalf("the error must name the duplicate, got %q", err) + } + }) + } + // register_neg is a LIST, not a keyed category — a repeated lexeme is tolerated and must NOT trip the guard. + if _, err := parseDCCheckers([]byte(base + "register_neg\tтерем\n")); err != nil { + t.Fatalf("a repeated register_neg lexeme (a list value, not a key) must be tolerated, got %v", err) + } +} diff --git a/backend/internal/llm/llm.go b/backend/internal/llm/llm.go index 870704dd..5436c33f 100644 --- a/backend/internal/llm/llm.go +++ b/backend/internal/llm/llm.go @@ -66,9 +66,9 @@ const ( // Deliberately absent (vs vojo): Tools (tool-calling out of MVP scope per Р2) and // ConvID (xAI-specific cache hint; bring it back with the xAI adapter if needed). type LLMRequest struct { - Model string - Messages []Message - MaxTokens int + Model string + Messages []Message + MaxTokens int // Temperature: how (and whether) it reaches the wire is decided per-model // by the Capability resolver (capability.go: send incl. explicit 0 | omit | // force) — the adapter no longer gates on > 0. diff --git a/backend/internal/membank/mempostcheck.go b/backend/internal/membank/mempostcheck.go index 2b1aceb1..8778f623 100644 --- a/backend/internal/membank/mempostcheck.go +++ b/backend/internal/membank/mempostcheck.go @@ -170,8 +170,10 @@ func singleHanKeyFired(via string) bool { // hard-gate promotion (postcheck_gate) MUST re-measure recall on common-noun terms, not // assume it — the "recall unaffected" phrasing in the D24.4 rationale is imprecise here. // The remaining inflection_gap (18/55: a plural rendered but only the singular seeded) is a -// SEED completeness fix (Polygon), not a code one. noutRunes is the normalized output -// pre-decomposed. +// SEED completeness fix (Polygon), not a code one. NB target ALIASES: the accepted set is base +// dst ∪ decl forms — an alternative TARGET rendering (a synonym/nickname the model may legitimately +// use) is NOT matched here, and adding one is a SEED concern (seed it as a decl form / alias), not +// code, on the same footing as the inflection_gap. noutRunes is the normalized output pre-decomposed. func dstFormPresent(e *entry, noutRunes []rune, outWords []string, stemmer lang.TargetStemmer) bool { if base := text.NormalizeTargetForm(e.dst); base != "" { if containsWholeWord(noutRunes, []rune(base)) { diff --git a/backend/internal/membank/mempostcheck_test.go b/backend/internal/membank/mempostcheck_test.go index f3ac3663..c7979ff1 100644 --- a/backend/internal/membank/mempostcheck_test.go +++ b/backend/internal/membank/mempostcheck_test.go @@ -49,10 +49,11 @@ func TestMultiHanKeyMissStaysConfirmed(t *testing.T) { } func TestSingleNonHanKeyMissStaysConfirmed(t *testing.T) { - // The #10 demote is Han-ONLY: Han alone lacks segmentation. A single-rune NON-Han key — a lone katakana - // ロ firing as a substring of ゼロ — must NOT be demoted; it stays a CONFIRMED miss (and carries no Demoted - // flag). This kills the mutant that demotes on len(r)==1 ALONE (dropping the unicode.Han guard in - // singleHanKeyFired), which the rest of the suite otherwise survives. + // The #10 demote is Han-ONLY by RATIFIED SCOPE, not because kana is segmented: a lone katakana ロ firing as + // a substring of ゼロ is equally weak evidence — kana is NOT boundary-checked either (memory.go:536). The + // demote is deliberately scoped to Han until the B6 tokenizer (backlog row 81), so a single NON-Han key must + // stay a CONFIRMED miss (and carry no Demoted flag). This kills the mutant that demotes on len(r)==1 ALONE + // (dropping the unicode.Han guard in singleHanKeyFired), which the rest of the suite otherwise survives. e := gl("ロ", "Зеро", "", "approved") e.AllowShort = true b := bankFrom([]store.GlossaryEntry{e}) @@ -68,3 +69,30 @@ func TestSingleNonHanKeyMissStaysConfirmed(t *testing.T) { t.Errorf("a non-Han single-rune miss must not be demoted to Unverified, got %d", len(res.Unverified)) } } + +func TestAmbiguousMissNotDemoted(t *testing.T) { + // The negative twin of the #10 demote: an AMBIGUOUS (auto/draft) injection that misses lands in Unverified + // as the model's ENTITLED refusal, not as a demotion. It must carry Demoted=false — Demoted is a + // single-Han-KEY-evidence axis (a SIGNED miss on weak evidence), orthogonal to the injection trust. This + // kills the mutant `m.Demoted = true` in Postcheck's default branch, which the rest of the suite otherwise + // survives (every other Unverified case is a demoted CONFIRMED miss, so none pins Demoted=false on ambiguous). + e := gl("元石", "первокамень", "", "auto") // auto → Ambiguous injection; 2-rune Han key → no #10 demote + b := bankFrom([]store.GlossaryEntry{e}) + sel := b.Select("他手里握着元石。", 1, nil, 0) + if len(sel.Injected) != 1 { + t.Fatalf("2-rune auto key should fire, got %d", len(sel.Injected)) + } + res := b.Postcheck(sel.Injected, "Он держал в руке камень.") // «первокамень» absent → a miss + if res.ConfirmedCount() != 0 { + t.Errorf("an ambiguous miss must not be CONFIRMED, got %d", res.ConfirmedCount()) + } + if len(res.Unverified) != 1 { + t.Fatalf("the ambiguous miss must survive as Unverified, got %d", len(res.Unverified)) + } + if res.Unverified[0].Demoted { + t.Errorf("an ambiguous miss must carry Demoted=false (Demoted is a single-Han-key axis, not the injection trust), got %+v", res.Unverified[0]) + } + if res.Unverified[0].Disp != "ambiguous" { + t.Errorf("the ambiguous miss must keep disp:ambiguous, got %q", res.Unverified[0].Disp) + } +} diff --git a/backend/internal/miner/miner_patterns.go b/backend/internal/miner/miner_patterns.go index fc49c818..55f2f476 100644 --- a/backend/internal/miner/miner_patterns.go +++ b/backend/internal/miner/miner_patterns.go @@ -16,6 +16,13 @@ import ( // schema, distinct from the DATA. The surname / title-topo-rank inventories / particle / Palladius tables // this file's channels consult now live in a langpack (internal/lang, configs/langpacks/), content-hashed // by Pack.Version(); this const versions the pattern CHANNELS, not the tables (D39.15: data as files). +// +// Row 89 (name honesty): "-universal-" names THIS channel-schema layer (a language-neutral versioning slot), +// NOT a claim the miner is language-agnostic — several channels (surname-start, Palladius) are zh-shaped (see +// the generality map, buckets B/C). It is carried on Config.PackVersion, which is currently WRITE-ONLY: never +// read, not folded into any snapshot/parity/output — a latent fold hook, not a live axis. Kept as-is (an +// unread version string must not churn: were a future --resnapshot to fold it, editing the value would move +// that snapshot spuriously). Deleting the dead field is the bucket-D alternative — the orchestrator's call. const minerPackVersion = "zh-universal-v1" // isSurnameStart returns the surname prefix (compound preferred) if s starts with one, else "" (patterns. diff --git a/backend/internal/miner/miner_substrate.go b/backend/internal/miner/miner_substrate.go index df5b485c..0ab94054 100644 --- a/backend/internal/miner/miner_substrate.go +++ b/backend/internal/miner/miner_substrate.go @@ -119,18 +119,6 @@ func candidateChapters(candNorm string, chunks []Chunk) map[int]bool { return chs } -// freqStratum buckets an occurrence count (exp16_common.freq_stratum). -func freqStratum(f int) string { - switch { - case f >= 10: - return "f>=10" - case f >= minerFreqFloor: - return "f3-9" - default: - return "f<3" - } -} - // --- general-zh contrast corpus (jieba dict.txt: "word freq POS" per line) ------------------------ // Contrast is the general-domain zh reference: normalized word frequencies + derived char frequencies, diff --git a/backend/internal/pipeline/chunkrun.go b/backend/internal/pipeline/chunkrun.go index 58f6c165..4766bf57 100644 --- a/backend/internal/pipeline/chunkrun.go +++ b/backend/internal/pipeline/chunkrun.go @@ -183,6 +183,7 @@ func (r *Runner) cheapGateConfig() checks.CheapGateConfig { YoPolicy: r.Book.YoPolicy, Allowlist: allow, RegressionEnabled: r.Pipeline.Gates.RegressionGuard.Enabled, - Checkers: r.checkers, // compiled once in openRunner (pair-14 data-out); nil-inert for a no-pack book + Checkers: r.checkers, // compiled once in openRunner (pair-14 data-out); nil-inert for a no-pack book + RegisterBlocklist: r.Book.RegisterBlocklist, // DC6 out-of-register lexis, from THIS book (D39.79 Q4) } } diff --git a/backend/internal/pipeline/miningstop_join_test.go b/backend/internal/pipeline/miningstop_join_test.go index 16c09f3e..55c98e57 100644 --- a/backend/internal/pipeline/miningstop_join_test.go +++ b/backend/internal/pipeline/miningstop_join_test.go @@ -310,7 +310,9 @@ func TestMiningStopWHATSurvivesResume(t *testing.T) { func isTerminologyBody(body string) bool { return strings.Contains(body, "Термины книги") } // isClassifierBody detects the §2 classifier call by its distinct fixture user header. -func isClassifierBody(body string) bool { return strings.Contains(body, "Классы терминов") } +func isClassifierBody(body string) bool { + return strings.Contains(body, "Классы терминов") +} // TestClassifierCorrectsTypeBeforeRender is the §2 pin: a focused pass re-types a candidate BEFORE the // render, and the corrected type reaches the banked row (attachClassifiedType) and the run's counters and diff --git a/backend/internal/store/backup.go b/backend/internal/store/backup.go new file mode 100644 index 00000000..52c707fc --- /dev/null +++ b/backend/internal/store/backup.go @@ -0,0 +1,102 @@ +package store + +// backup.go: the F4 SPOF guard (backlog row 83). The owner's SIGNED bank, every checkpoint and the spend +// ledger live in ONE SQLite file per project, so a paid run must (1) leave a fresh, consistent restore point +// before it starts and (2) refuse to start on a corrupt file — a silent loss of the signed bank is +// unrecoverable. Both are cheap, deterministic and provider-free. The caller (tmctl) decides WHEN to run +// this; here we only verify integrity and copy. + +import ( + "database/sql" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" +) + +// IntegrityCheck runs `PRAGMA integrity_check` against the SQLite file at dbPath and returns a loud error +// unless the check reports the single row "ok". It opens a private read-only connection (query_only), so it +// can never mutate the file it is verifying and is safe to run against a database another reader holds. +func IntegrityCheck(dbPath string) error { + db, err := openForBackup(dbPath, true) + if err != nil { + return err + } + defer db.Close() + return integrityCheck(db, dbPath) +} + +// BackupSQLite verifies the project database's structural integrity and then writes a transactionally +// consistent copy to backupDir/.db via `VACUUM INTO` — the SQLite-blessed online-backup path (a +// defragmented snapshot, no torn WAL frames). stamp is supplied by the caller (a timestamp) so the backup +// name is deterministic under test. A non-"ok" integrity_check is a LOUD error and NO backup is written (a +// corrupt source must never be trusted as a restore point). It refuses to overwrite an existing backup, so a +// caller cannot silently clobber a good restore point. Returns the backup file's path. +func BackupSQLite(dbPath, backupDir, stamp string) (string, error) { + if _, err := os.Stat(dbPath); err != nil { + return "", fmt.Errorf("store: backup source %s does not exist: %w", dbPath, err) + } + db, err := openForBackup(dbPath, false) // VACUUM INTO is a write statement — query_only would block it + if err != nil { + return "", err + } + defer db.Close() + if err := integrityCheck(db, dbPath); err != nil { + return "", err + } + if err := os.MkdirAll(backupDir, 0o755); err != nil { + return "", fmt.Errorf("store: backup dir %s: %w", backupDir, err) + } + backupPath := filepath.Join(backupDir, stamp+".db") + if _, err := os.Stat(backupPath); err == nil { + return "", fmt.Errorf("store: backup %s already exists (refusing to overwrite a restore point)", backupPath) + } + // VACUUM INTO takes a string literal path; the single-quote is the only SQL metachar, escaped by doubling. + if _, err := db.Exec("VACUUM INTO '" + strings.ReplaceAll(backupPath, "'", "''") + "'"); err != nil { + return "", fmt.Errorf("store: VACUUM INTO %s: %w", backupPath, err) + } + return backupPath, nil +} + +// openForBackup opens a private single connection to dbPath. readOnly adds query_only(1) for a pure check; +// the backup path needs a writable connection because VACUUM INTO is classified as a write statement (it +// still only READS the source and writes a separate file). +func openForBackup(dbPath string, readOnly bool) (*sql.DB, error) { + v := url.Values{} + v.Add("_pragma", "busy_timeout(5000)") + if readOnly { + v.Add("_pragma", "query_only(1)") + } + db, err := sql.Open("sqlite", "file:"+dbPath+"?"+v.Encode()) + if err != nil { + return nil, fmt.Errorf("store: open %s for backup: %w", dbPath, err) + } + db.SetMaxOpenConns(1) + return db, nil +} + +// integrityCheck runs the pragma over db and demands the single canonical "ok" row; anything else (a list of +// corruption reports, or a "file is not a database" open error surfaced here) is a loud failure. +func integrityCheck(db *sql.DB, dbPath string) error { + rows, err := db.Query("PRAGMA integrity_check") + if err != nil { + return fmt.Errorf("store: integrity_check %s: %w", dbPath, err) + } + defer rows.Close() + var lines []string + for rows.Next() { + var s string + if err := rows.Scan(&s); err != nil { + return fmt.Errorf("store: integrity_check %s scan: %w", dbPath, err) + } + lines = append(lines, s) + } + if err := rows.Err(); err != nil { + return fmt.Errorf("store: integrity_check %s: %w", dbPath, err) + } + if len(lines) != 1 || lines[0] != "ok" { + return fmt.Errorf("store: integrity_check FAILED for %s: %v", dbPath, lines) + } + return nil +} diff --git a/backend/internal/store/backup_test.go b/backend/internal/store/backup_test.go new file mode 100644 index 00000000..66cefc06 --- /dev/null +++ b/backend/internal/store/backup_test.go @@ -0,0 +1,79 @@ +package store + +import ( + "os" + "path/filepath" + "testing" +) + +// TestBackupSQLiteCreatesValidRestorePoint pins the F4 happy path: a green integrity_check followed by a +// VACUUM INTO copy that is itself a valid, integrity-clean, openable SQLite project database. +func TestBackupSQLiteCreatesValidRestorePoint(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "book.db") + s, err := Open(dbPath) // a real, migrated project DB + if err != nil { + t.Fatalf("open source: %v", err) + } + s.Close() + + backupDir := filepath.Join(dir, "backups") + const stamp = "20260802T000000Z" + path, err := BackupSQLite(dbPath, backupDir, stamp) + if err != nil { + t.Fatalf("backup: %v", err) + } + if want := filepath.Join(backupDir, stamp+".db"); path != want { + t.Fatalf("backup path = %q, want %q", path, want) + } + if _, err := os.Stat(path); err != nil { + t.Fatalf("backup file missing: %v", err) + } + // The backup must itself pass integrity and be openable as a store (a real, migrated copy). + if err := IntegrityCheck(path); err != nil { + t.Errorf("the backup failed its own integrity_check: %v", err) + } + s2, err := Open(path) + if err != nil { + t.Fatalf("backup is not openable as a project store: %v", err) + } + s2.Close() +} + +// TestBackupRefusesCorruptSource pins the SPOF half: a source that is not a valid SQLite file fails loud and +// writes NO backup — a corrupt bank must never be laundered into a trusted restore point. +func TestBackupRefusesCorruptSource(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "corrupt.db") + if err := os.WriteFile(dbPath, []byte("this is not a sqlite database, just garbage bytes"), 0o644); err != nil { + t.Fatal(err) + } + backupDir := filepath.Join(dir, "backups") + if _, err := BackupSQLite(dbPath, backupDir, "x"); err == nil { + t.Fatal("a corrupt source must fail loud, not silently produce a backup") + } + if _, err := os.Stat(filepath.Join(backupDir, "x.db")); err == nil { + t.Error("no backup file may be written when the source fails integrity") + } +} + +// TestBackupRefusesMissingSource and refuses to overwrite an existing restore point. +func TestBackupRefusesMissingSourceAndOverwrite(t *testing.T) { + dir := t.TempDir() + if _, err := BackupSQLite(filepath.Join(dir, "nope.db"), filepath.Join(dir, "b"), "x"); err == nil { + t.Fatal("a missing source must be a loud error") + } + dbPath := filepath.Join(dir, "book.db") + s, err := Open(dbPath) + if err != nil { + t.Fatal(err) + } + s.Close() + backupDir := filepath.Join(dir, "backups") + if _, err := BackupSQLite(dbPath, backupDir, "same"); err != nil { + t.Fatalf("first backup: %v", err) + } + if _, err := BackupSQLite(dbPath, backupDir, "same"); err == nil { + t.Fatal("a second backup at the same stamp must refuse to overwrite the restore point") + } +} diff --git a/backend/internal/terminology/classify_test.go b/backend/internal/terminology/classify_test.go index 88b86a22..14c355a5 100644 --- a/backend/internal/terminology/classify_test.go +++ b/backend/internal/terminology/classify_test.go @@ -10,12 +10,12 @@ func TestParseTypesKeepsOnlyAskedAndVocabulary(t *testing.T) { id := func(s string) string { return s } reply := strings.Join([]string{ "元石\tterm", - "方源 name", // spaces instead of a tab — tolerant split - "青茅山 | place", // padded pipe - "家老\tPERSON", // off-vocabulary → refused and counted - "陌生\tterm", // never asked → refused and counted - "元石\tname", // duplicate key → first answer wins - "мусор", // one field → refused and counted + "方源 name", // spaces instead of a tab — tolerant split + "青茅山 | place", // padded pipe + "家老\tPERSON", // off-vocabulary → refused and counted + "陌生\tterm", // never asked → refused and counted + "元石\tname", // duplicate key → first answer wins + "мусор", // one field → refused and counted }, "\n") got, st := ParseTypes(reply, []string{"元石", "方源", "青茅山", "家老"}, id) want := map[string]string{"元石": "term", "方源": "name", "青茅山": "place"} @@ -42,10 +42,10 @@ func TestParseTypesNormalizesKey(t *testing.T) { // prevents that harm; asserting the miss keeps a future reader from mistaking this for the guard. func TestTypeLabelMismatchesIsHonest(t *testing.T) { rows := []LabelRow{ - {Src: "花家", Type: "name", Dst: "Дом Хуа"}, // translated name → FLAG - {Src: "青茅山", Type: "place", Dst: "гора Цинмао"}, // translated place → FLAG - {Src: "元石", Type: "name", Dst: "юаньши"}, // the harm, single lower-case token → MISSED - {Src: "元海", Type: "name", Dst: "Юаньхай"}, // the harm, capitalised token → MISSED + {Src: "花家", Type: "name", Dst: "Дом Хуа"}, // translated name → FLAG + {Src: "青茅山", Type: "place", Dst: "гора Цинмао"}, // translated place → FLAG + {Src: "元石", Type: "name", Dst: "юаньши"}, // the harm, single lower-case token → MISSED + {Src: "元海", Type: "name", Dst: "Юаньхай"}, // the harm, capitalised token → MISSED {Src: "灵泉", Type: "term", Dst: "духовный источник"}, // term is not screened → not flagged } got := TypeLabelMismatches(rows) diff --git a/backend/internal/terminology/series_test.go b/backend/internal/terminology/series_test.go index 1c0f6d2f..bb9a78d8 100644 --- a/backend/internal/terminology/series_test.go +++ b/backend/internal/terminology/series_test.go @@ -85,7 +85,9 @@ func TestDetectSeriesOnBankFullFixtureIsStable(t *testing.T) { var zhSeries = SeriesParams{Enabled: true, HeadFinal: true} -func cand(key string, freq int) Candidate { return Candidate{Key: key, Src: key, Type: "term", Freq: freq} } +func cand(key string, freq int) Candidate { + return Candidate{Key: key, Src: key, Type: "term", Freq: freq} +} // TestDetectSeriesHeadAware pins the head-aware rule against the exact patterns the naive "differ in one // position" rule broke on (measured on BANK-FULL): a grade set clusters, but a set differing IN the head diff --git a/backend/prompts/zh-ru/editor-mono.md b/backend/prompts/zh-ru/editor-mono.md index d1429061..31caeb55 100644 --- a/backend/prompts/zh-ru/editor-mono.md +++ b/backend/prompts/zh-ru/editor-mono.md @@ -1,3 +1,8 @@ + Ты — монолингвальный литературный редактор перевода на русский язык. Ты видишь ТОЛЬКО черновик перевода, без исходного текста. Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}». diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index bbc0a297..0897335c 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,8 +1,8 @@ # Журнал прогресса -> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-02, голова D39.80). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md`; этот файл — ЖУРНАЛ.** +> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-08-02, голова D39.82). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md`; этот файл — ЖУРНАЛ.** > - **Сделано (сводно; детали — D-лог и архив-слайсы):** Ф0 ✅ · Ф1-инфра ✅ · арх-ресет D39 (7 слоёв, паки 11–16) ✅ · паки 17 «канал B» · 18 «долги» · 19 «голос/состояние» · 20 «банк+терминолог» ✅ (D39.26–28/31/41–56) · мини-прогон (D39.37) и ХОЛОДНЫЙ прогон (D39.58: recall банка 0.918/0.980, банк приходит переведённым) приняты · полигон-пакеты 5–8, ToS-речек (D39.57), Р6+P4 (D39.61) закрыты · **карта языковой привязки движка (D39.60): 149 сайтов/45 файлов, книго-ось чиста (4), ja→ru безопасно без правки Go, en→ru — нет** · Р1–Р4 закрыты целиком (D39.63: Р2 = ЗНАЧЕНИЕ, Р4 = норма потолка recall) · **ФАЗА 2 ОБЩНОСТИ ✅ (D39.64: П0 эмбед-хеш · П1 цель-шов · П2 скрипт-шов, ko-баг закрыт · П3 нарезка · П4 манифест-по-каналам; голден вердикт-нейтрален 0/172, майнер-парити EXACT)** · жанровый словарь отменён как класс (D39.47) · петля ремонта построена и НЕ включена (D39.38). -> - **Курс (D39.59–78): ОБЩНОСТЬ ✅ → КАЧЕСТВО БАНКА ✅ (D39.69/75/77) → ПАКЕТ-ЧЕКЕРОВ ✅ (D39.78: строка 25 целиком; харнесс labels В GIT 12/12; K2 r0.85 · K4b r0.22 · K6 fp 14→6; Р2 hard/soft + Р4-потолки в контрактах).** **Текущее: МЕЛКАЯ ПАЧКА ХВОСТОВ в работе (строки 89·93·53·83 + решения D39.79, промт `BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md`); вопросы §6 РЕШЕНЫ делегированием (D39.79: канон «класс X» · 兩 в numeral-классах · value-чек 成 загейчен строкой 12 · register_neg → книжный слой); ре-проба flash (строка 74) — по слову владельца; finding-1 ЗАКРЫТ (D39.77); онбординг-оверхол доков исполнен (D39.80); **ФРОНТ ОТКРЫТ владельцем (D39.81: продукт = SaaS; зоны `frontend/` + `platform/` живые, фронт работает на моках; движковая очередь НЕ пере-упорядочена — ранняя вставка одна: строка 95 «контракт API» $0; строки 96/97 и перевесы 49/94 заведены)**.** ⚠ **DeepSeek-V4-Flash-0731: платные прогоны СТОП до ре-пробы (строка 74; там же 6/6-порог классификатора и слоты Q2/Q7). Перекупок нет (D39.63).** +> - **Курс (D39.59–78): ОБЩНОСТЬ ✅ → КАЧЕСТВО БАНКА ✅ (D39.69/75/77) → ПАКЕТ-ЧЕКЕРОВ ✅ (D39.78: строка 25 целиком; харнесс labels В GIT 12/12; K2 r0.85 · K4b r0.22 · K6 fp 14→6; Р2 hard/soft + Р4-потолки в контрактах).** **Текущее: МЕЛКАЯ ПАЧКА ✅ ПРИНЯТА И ЗАЛЕНДЕНА (D39.82: строки 89/53/83 закрыты · 93 сужена до остатков · F4-бэкап+pre-flight построен · register/жанр → книжный слой · DC7 пере-гейчен сид-покрытием → строка 98); решения §6 исполнены (D39.79/82); ре-проба flash (строка 74) — по слову владельца; finding-1 ЗАКРЫТ (D39.77); онбординг-оверхол доков исполнен (D39.80); **ФРОНТ ОТКРЫТ владельцем (D39.81: продукт = SaaS; зоны `frontend/` + `platform/` живые, фронт работает на моках; движковая очередь НЕ пере-упорядочена — ранняя вставка одна: строка 95 «контракт API» $0; строки 96/97 и перевесы 49/94 заведены)**.** ⚠ **DeepSeek-V4-Flash-0731: платные прогоны СТОП до ре-пробы (строка 74; там же 6/6-порог классификатора и слоты Q2/Q7). Перекупок нет (D39.63).** > - **Горизонт (D39.62/67):** ре-проба flash (74; + платный порог 6/6 классификатора + пробы 36б-замера Q2/эмиссии Q7 по слову) → **ДОБОР ИДЕАЛА** (первым прогоном: оси голоса 24 · авто-режим · итерация №2 редакторов 65 · цена 16 · веса K1–K12 13а · вне-претрейн чекпоинт 55) → ВТОРАЯ ПАРА живьём (ja→ru; преп 81) → МАСШТАБ → пилот Ф2.5 (гейт резюме-строки 80; строки 62–68, 85) → Ф3 ридер-IDE (69–71). **Стоячие:** ToS-триггер 25.10 · Ш-2 до go1.27 · строка 74 перед любым платным прогоном. > - **Стек:** draft deepseek-v4-flash thinking-ON (+банкнота) **⚠0731** → терминолог (та же модель, батчи, экран `target_script`) → editor deepseek-v4-pro БИЛИНГВ ИНТЕРИМ (вендором НЕ тронут; glm-5 резерв) → судья gemini (Ф2, полигон); канал B Mistral+grok; ~$0.85/ранобэ (D30.4) — пере-калибровка после развилки 74. > - **ЕДИНЫЙ БЭКЛОГ — секция «Бэклог» ниже** (одна таблица, единственный трекер; каждая петля обязана иметь диспозицию: решено / отложено-с-записью / отклонено; ведёт оркестратор). @@ -38,16 +38,15 @@ | 74 | ⚠ **DeepSeek-V4-Flash-0731: боевая черновая форма покупает пустые ответы — блокер ПЛАТНЫХ прогонов.** Оперативное состояние = «(в) ждать» (D39.63; фаза-2 всё равно $0-код). Перед следующим платным прогоном — ре-проба flash за копейки → если не устаканилось, выбор: (а) флор 16000 (сдвиг request_hash безболезнен — перекупок на этой фазе нет; хвост не закрывает, упор 2/5) или (б) слать `reasoning_effort` явно (вендорская модель, дешевле ~22%; но амендмент `echoMineViolation`, эхо ~6% и качество черновика при `low` не судилось — нужен замер ~$0.05; тогда же предмет для постинструкции/префилла); включение ручки #77 (ре-ген эха перед эскалацией, построена D39.64, дефолт 0) — тем же решением; при включении помнить общий счётчик attempt с length-ре-геном + рост `maxTokensForAttempt`; остаток §12.7 (8 несудимых fidelity-пар ≈$0.10 · ja-вход ≈$0.005 · prefill-continuation research/21 §1.17) — вернуться при предмете wiring; ⚠ cap 8000 бьёт и СТАДИЮ ТЕРМИНОЛОГА (reasoning 8063/батч, D39.65) — резолюция обязана накрыть терминолога; тем же заходом: платный порог приёмки §2 классификатора (6/6 harm-набор, ~$0.01, D39.69) + полигон-слоты Q2 (36б-замер) и Q7 (эмиссия головы, после #10) | владелец (по ре-пробе) | перед следующим платным прогоном | ре-проба → решение | D39.61, D39.63, D39.64, POLYGON_PROMPTLANG_ANTIECHO §12.4 | | 78 | **Леджер денег = НИЖНЯЯ граница**: «2xx body decode failed (call IS billed)» — провайдер списал, исходная попытка в `request_log` НЕ попадает (≤$0.009/сессия); дизайн фикса ГОТОВ (GENERALITY_PHASE2 §5.6: `BilledDecodeFails` + per-billed-attempt settle с `Estimated=true`), но это money/ledger-путь — не $0-хастл | бэкенд | когда-нибудь (money-паком) | отдельное решение (дизайн §5.6) | D39.61, D39.64 | | 79 | **Граница ПЛОТНОСТИ письма для серий**: `enabled` через Go-константу `cjkScriptNames` — новый плотный скрипт требует правки Go (head-finality уже в данных script-series.txt; пре-существующая граница, D39.75). Первые два компонента строки (isCyrLetter→`isTargetWordLetter` · magnitude `SourceScripts`-гейт) исполнены пакетом-чекеров, D39.78 | бэкенд | когда-нибудь | следующий пак общности / касание чекеров | D39.64, D39.78 | -| 89 | **Остатки карты общности D39.60 без носителя** (пропуск свипа, D39.72); СУЖЕНО D39.78 — checkers-срез ведра D исполнен пакетом (6 указателей `checkers_zh_ru.go` + ложная шапка checkers.go). Остаток: дубль isHeaderContentRune↔isHeaderSeparator · minerPackVersion «zh-universal-v1» · глифы ⟨проверить⟩ tmctl/render · вес palladius miner.go (дюжину сверить по карте §3) + сверка ведра B (6 «пар-модуль без гейта») против фазы-2 | бэкенд | в работе (промт выдан D39.79) | `BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md` | D39.60, D39.66, D39.72, D39.78 | | 90 | **Ф2-кандидаты 02-mvp-плана без носителя** (свип-хвост, D39.73): CometKiwi/T-index метрики судьи · Batch API судьи (экономия) · llama-server-интеграция локали · стриминг — взять/закрыть при пилот-препе; + H16 самоулучшение (V3-п.2, ПТ-26): дизайн петли по данным судьи — тем же препом | полигон/бэкенд | когда-нибудь (Ф2.5) | пилот-преп-промт (с 85) | 02-mvp-plan, D39.66, D39.73, D39.80 | | **— НАХОДКИ СВИПА ПОЛНОТЫ (D39.66: 951 обязательство проверено, потери возвращены в трекер) —** | | | | | | | 80 | **Резюме-слой памяти** — проза-суммарайзер ЗАКРЫТ (D39.69); строка = ГЕЙТ ПИЛОТА Ф2.5: первый деливерабл пилота — «допускает ли автономная нарратив-состояние-строка ДЕТЕРМИНИРОВАННЫЙ верификатор (source-anchored) — или это D1-компаундинг со схемой»; не-покрытые классы (source-anchored reveal · арк-колбэки без ключа) реальны, но не измерены как дефект | Ф2.5 (Q3 подтверждён D39.70) | когда-нибудь (пилот) | Ф2.5 пре-рег | D25 п.8–9, D39.66, D39.69 | | 81 | **ja-преп B6-ja (сужено пост-сверкой D39.66)**: Поливанов-валидатор (`translit_policy` = «Phase 2»-заглушка, migrate.go:186/194) + kana-омограф POS/known-word гейтинг / B6-токенизатор (memory.go:1048); чек-лист kana-алиасов сида УЖЕ построен (backend/README §ja + `AttachRubyAliasesToManual`) — остаток в нём только дизамбигуация двойных чтений (тот же B6) | бэкенд | когда-нибудь (перед ja→ru) | ja→ru-пак (рядом 35) | D16.4, D17.1, D18, D39.66 | | 82 | **Морфо-гейт РОДА** (C3: русский глагол прош. вр. при gender=hidden = механический спойлер-канал; «жалоба №1 читателей MTL»; python-сайдкар/pymorphy, связка с морфопроходом Decl из 73) | бэкенд | когда-нибудь (Ф2-гейты) | отдельный пак (с 52) | 06-реестр C3, research/12, D39.66 | -| 83 | **F4: бэкап + integrity_check SQLite-файла книги перед платным прогоном** (VACUUM INTO; SPOF — тихая потеря банка и подписей владельца); ПОДТЯНУТА D39.79 с «до МАСШТАБА» на «до платных прогонов добора идеала» | бэкенд | в работе (промт выдан D39.79) | `BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md` | 06-реестр F4, D39.66, D39.79 | | 85 | **Пилот-преп добор** (к переупаковке PACKAGE4): Bertalign GOLD-пары + zh→ru spot-check (D29.2в; GPL-тулинг отдельно) · fidelity-каскад R2 shadow с корпус-подготовкой (D25.2) · exp08 v3 пере-съём COGS до фиксации budget_usd (D21.8) | полигон | когда-нибудь (Ф2.5) | пилот-преп-промт | D25, D29, D21.8, D39.66 | | **— ПАК-21 «чекеры»: РАСТВОРЁН (D39.62); шестёрка исполнена 2/6 фазой 2 (D39.64: #11 · строка 26), остаток — ниже —** | | | | | | -| 93 | **Хвосты пакета-чекеров** (минорки двухраундового ревью D39.78 — комменты/гвозди/данные одним касанием): строгий OffLanguage (36а, deferred) · разметить 分之-срез прежде стройки DC2 word↔word (полигон) · substring-вето `isSpokenChevronLine` → whole-word (второй call-site маркер-листа; гасит mixing-клэш на «в умении») · recall-трейд двустороннего отсечения `speechAttribution` вписать в контракт (маркер после внутренней запятой/многоточия в атрибуции = FN правила, не данных) · негатив-ассерт Demoted=false на ambiguous-популяции · stale-коммент `repair.go` LatinResidueCount «lowercase» · CAND k4_inverse в labelcandidates = опровергнутый whole-line вариант (заморозить/пометить по образцу фикса-3) · конвенции «..»-vs-«…» и thin-space джойна (код↔коммент) · кана-оговорка в обосновании Han-only демоции (класс открыт до B6, строка 81) · target-алиасы пост-чек-матча (сид-концерн) | бэкенд/полигон | в работе (промт выдан D39.79; 分之-разметка остаётся полигону) | `BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md` | D39.78 | +| 93 | **Остатки строки 93 после пачки (D39.82):** строгий OffLanguage (36а, deferred D39.78) · 分之-разметка прежде стройки DC2 word↔word (полигон) · F4-хвост: `redrive --dry-run` обходит pre-flight, а `store.Open` мутирует SPOF-файл (миграции + recoverReservations; коммент «mutates nothing» неверен) — pre-flight и на dry-run при существующей БД либо read-only план; заодно stat-скип точной формой `fs.ErrNotExist` | бэкенд/полигон | когда-нибудь | малое касание / полигон-слот | D39.78, D39.82 | +| 98 | **DC7 грейд-консистентность — пере-гейчен по сид-покрытию (D39.82):** замер пачки реален (25 юнитов/93 «разряд» ≠ канон «класс» на labels-финалах), НО финалы — прогоны до/вне сид-v2-канона, а грейды 甲/乙/丙/丁等 теперь approved-записи сида с decl-формами: mempostcheck флагнёт «разряд» промахом dst; строить DC7 только если ОСТАТОК девиаций переживёт сид-покрытие — замер на первом СИДОВОМ прогоне добора идеала; дом канона при стройке = book.yaml (как register_blocklist; ратифицировано D39.82 — НЕ dc-checkers.txt, посылка D39.79 исправлена) | бэкенд | когда-нибудь (гейт: первый сидовый прогон добора идеала) | замер остатка → решение | D39.79, D39.82, SMALLPACK §5-E1 | | 28 | Банк-линт латиницы в dst или строгая форма языкового предиката (7 строк утечки алфавита проходят экран) | бэкенд | когда-нибудь (добор идеала — носитель «свежий мини-прогон» растворён D39.67, актуализация D39.79) | касание чекеров при данных добора (помнить оговорку 36а: «甲等 → класс Цзя» строгой формой не ловится) | D39.52, D39.62, D39.64, D39.79 | | 28а | **Prompt-injection-проба входного текста** ($0): сепаратор ⟦TM-BANK-v1⟧ и якорь-подобные маркеры В ТЕКСТЕ КНИГИ — поведение среза/парсера/инъекции (книга = недоверенные данные; инструментов у моделей нет, но канал банкноты читает вывод по маркеру) | бэкенд/полигон | когда-нибудь | малая проба | сводка-ревью 26.07 | | **— СВИП ГИПОТЕЗ —** | | | | | | @@ -80,7 +79,6 @@ | 50 | F3-остаток идемпотентности | бэкенд | когда-нибудь | отдельное решение | D39.34(4) | | 51 | `human_override` LOCK (D29 п.1г, в коде 0 вхождений) | бэкенд | когда-нибудь | отдельное решение | D39.34(4) | | 52 | **Ф2-гейты вне идеала**: морфо-гейты канцелярита + L1-лемматизация (python-сайдкар) · полный OpenCC · опциональный пре-перевод гейт ; состав явно: канцелярит + РОД (строка 82) + класс Ф2-гейтов карты 04-unhappy (обсцен-гейт/феминитивы/время сцены — состав решить паком) ; + класс идиоматики (свип-хвост, D39.73): сехоуюй-словарь · вэньянь-классификатор (zh) · васэй-эйго ложные друзья (к ja-паку 81) — данные пары или закрытие «банк книги покрывает» тем же паком | бэкенд | когда-нибудь | отдельное решение | D39.34(4) | -| 53 | **Мелкие хвосты качества одним паком**: ledger-очередь LOW/NOTE (D39.4) · остаток фикс-листов D23 п.4 / D28 п.3(б) · `editor-mono.md` DORMANT · gofmt-нит `llm.go` (+3 теста) ; + внутрифайловый дубль ключа pair-файла → громкая ошибка (PACK15 §Внутрифайловый) | бэкенд | в работе (промт выдан D39.79) | `BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md` | D39.34(5), D39.45 | | 54 | Масштаб целой книги: волны/каденс/потолки/ETA на сотнях глав (гоняли максимум 10; книга 7,78 млн симв., ~2284 раздела) | бэкенд | когда-нибудь (последним по курсу) | МАСШТАБ | D39.33, CURRENT-STATE | | **— ПОЛИГОН (кандидаты пакетов) —** | | | | | | | 55 | **`dialogue_dash` мерить только на невиданном тексте** (подогнан под 蛊真人; цифры мини-прогона аргументом не считаются) ; расширено до вне-претрейн чекпоинта целиком: r-коэффициенты exp01/08 · precision чекеров · частота эха · чистовик exp15-выводов ; + arity-полоса паспорта (D29.2г): решить — dialogue_dash покрывает диалоговую половину или мерить (D39.73) | полигон | скоро | полигон-пакет | D39.37(4), D39.40, D39.46 | @@ -198,6 +196,8 @@ Q1 = П1 цель-шов санкционирован («главное бэке ## Бэкенд +**Мелкая пачка хвостов (строки 93·89·53·83 + решения D39.79) исполнена (02.08, бэкенд-сессия по `BACKEND_SMALLPACK_TAILS_SESSION_PROMPT`, D39.79, $0 — только фейк-провайдер в тестах, НЕ закоммичено; D-номер лендинга проставит оркестратор).** Отчёт: [archive/reports/SMALLPACK_TAILS_2026-08-02.md](archive/reports/SMALLPACK_TAILS_2026-08-02.md). **A (стр.93):** 8 хвостов — whole-word inner-marker вето `isSpokenChevronLine`+гвоздь · recall-потолок `speechAttribution` в контракт · негатив-ассерт `Demoted=false` (мутант убит) · stale `LatinResidueCount` коммент · CAND k4_inverse → боевое правило + REJECTED-контраст · «..»/thin-space комменты↔код · честная кана-оговорка · target-алиас NB. **B (стр.89):** ведро D — `isHeaderSeparator`→`!isHeaderContentRune` (консолидация), мёртвый `freqStratum` удалён, `minerPackVersion`/⟨проверить⟩-глиф честные комменты (парити EXACT); ведро B (6) сверено на носители (строки 35/81), бездомных нет → пинга нет. **C (стр.53):** gofmt чист (4 файла) · `editor-mono.md` DORMANT-маркер (`` strip'ается, hash-нейтрален, боевого SHA не двигает) · внутрифайловый дубль-ключ `parseDCCheckers` → fail-loud (`putUniqueDC`+тест; register_neg-список терпим) · D39.4/D23.4/D28.3(б) архео (парк/moot/уже-сделано). **D (стр.83, F4):** `tmctl backup` верб + боевой pre-flight (integrity_check + `VACUUM INTO ~/books/<книга>/backups/`); дискриминатор боевого пути = размещение в `run()`-диспетчере (тесты зовут `translate()`/Runner напрямую) — **ноль магических строк провайдеров в Go**; гвоздь «фейк-путь бэкапов не создаёт». **E (D39.79):** Q1 — канон «класс X» подтверждён (0 «разряд» в живом носителе → пинга нет); DC7-замер = **25 юнитов/93 девиации «разряд»≠канон → построй, НО** двойной стоп (книго-канон-дом vs пар-пак + >50 строк) → **СТОП+ПИНГ с дизайном, DC7 не построен** (грейд-канон = book.yaml, оркестратору). Q2 — 兩 в `shichen_re` (probe-литералы/cheng_re не тронуты) + NB-граница. Q4 — `register_neg`+жанр-msg → книжный слой (`book.yaml register_blocklist`→`CheapGateConfig`→DC6-union; `omitempty` в BriefHash → **golden brief_hash `c2021b1b…` неизменен**; пар-`register_neg`/`dc6_register` опциональны; терем-список → coldrun-a+example book.yaml; DC6-семантика та же; «вторая книга другого жанра — 0 ложных DC6, 0 правок Go» = ДА). **Приёмка:** build/vet/`-race ./...` зелёные · майнер-парити EXACT · **голден НЕ двинулся** (`CheapGateVersion` не бампнут) · labels-фрозен (k4b 10/1/36/99·k4_inverse 3/0/1/71·k2 34/1/6/51·K6 1/6/0/251·k4a 0/0/0/348, `assertBaseline` хард) · gofmt чист · греп-общность чист. **Плюс адверсариальный author≠reviewer (6-агентный refute-воркфлоу по своим правкам): 0 блокеров, 0 major; 2 CONFIRMED low-sev (диагностика/коммент) исправлены ДО отчёта** (source-neutral DC6-detail; `isTargetWordLetter`-атрибуция коммента), пост-фикс перепрогон зелёный. **СТОП — приёмка оркестратора.** — **ЛЕНД D39.82** (батарея пере-прогнана оркестратором независимо, все числа совпали; 5-осевой refute-воркфлоу: 0 блокеров/0 major против пакета — omitempty доказан пере-выводом hash, комплементарность рун — полным перебором; DC7-стоп ПОДТВЕРЖДЁН, но рекомендация пере-гейчена сид-покрытием mempostcheck → строка 98; F4-хвост dry-run-redrive → строка 93; строки 89/53/83 закрыты). + **Пакет-чекеров (строка 25 + 79) исполнен (02.08, бэкенд-сессия по `BACKEND_CHECKER_PACKAGE_SESSION_PROMPT`, D39.67/68, $0 — только фейк-провайдер в тестах, НЕ закоммичено).** Отчёт: [archive/reports/CHECKER_PACKAGE_2026-08-02.md](archive/reports/CHECKER_PACKAGE_2026-08-02.md). **Харнесс labels пересобран В GIT** (env-гейт `TM_CHECKER_LABELS=1`, `internal/checks/labelharness_test.go` + `internal/membank/labelharness_test.go` + candidates); `metrics.json` пакета-6 воспроизведён ПОБАЙТНО (12 ключей после постфикса — k4a маркер-онли; 2 независимых пути счёта совпали); k5c-конвенция названа (`codeconv`=фиделити кода / `unit`=цель Р2); две пост-банк-пак дельты объяснены (K6 стеммер 14→12; K3 combmark 0). **Построено:** #10 (пост-чек однознак. Han-ключ → Unverified observability, K6 fp 14→6, **fn=0 сохранён**) · #3 (атрибуция ёлочки +5 tp, K4b r0.11→0.22, Р4-потолок в контракт) · #6 (заглавная латиница +allowlist, K2 r0.75→0.85) · k4_inverse (inner_marker-на-тире, +3 tp, p1.0) · #8 (两/兩 данные, 0 эффекта = generality) · Р2 hard/soft split (`UnitScaleHard`/`Soft`, `Total` не тронут) · строка 79 (isCyrLetter→wordScript · DC1-трим · magnitude SourceScripts-гейт — закрыла и ведро C-2 register-boundary) · рефрен editor.md/mono · чистка ведра D (6 указателей `checkers_zh_ru.go` + ложная шапка checkers.go). **Закрыто с обоснованием:** #5 (единственный дефект DC2-покрыт) · #7 (порог=0-эффект; гомоглиф=`tk.mixed`, приёмка пройдена) · DC2 word↔word (нет размеченного дефекта) · research/22 (не на labels) · omission-бэкстоп (уже стеммером) · строгий OffLanguage (36а). **На владельца (§6 отчёта):** DC7 grade/role (коллизия канона класс/разряд) · 兩-traditional · Р2 value-детектор vs soft · register_neg в пар-паке. **Приёмка:** build/vet/`-race ./...` зелёные · майнер-парити EXACT · голден пере-захвачен РАЗ, маскированный дифф ПУСТ (двинулся только `style_check_version` v6→v7 + хеш-каскад; content_hash/disp/flag/memory_version/prompt_sha256/вердикты байт-идентичны) · греп-общность чист (нет новых пар/книго-ветвлений) · мут.тесты на новые классы. Якоря `13-tech-debt-anchors.md` актуализированы (#10 помечен исполненным). **Плюс адверсариальное агентское ревью (3 refute-biased агента, по вопросу владельца ПОСЛЕ лендинга): 2 корректностных FP найдено и ИСПРАВЛЕНО ДО приёмки** (k4_inverse маркер по всей строке → скоуп на атрибуцию; #3 плоская цитата `«X» —` → отвергается, принимаются только `«X», —`/`«X!/?/…» —`; оба observability, гвозди добавлены, labels не двинулись 10/1/36/99 · 3/0/1/71) + honesty-правка (K2 precision честно **0.971**, не «1.0 с allowlist» — конфиг бренд не заносит) + Р2 NumberMagnitude→HARD + DRY `lang.IsDenseScript` + подрезка комментариев (норма 26.07). Харнесс подтверждён честным (byte-for-byte, «before» из git; манифактуренной сходимости нет). — **ПОСТ-ФИКС D39.78 (приёмка оркестратора: 0 блокеров, 5 major + minors, $0, `CheapGateVersion` НЕ бампнут):** все закрыты, §8 отчёта. (1) `speechAttribution` режет сегмент атрибуции с ДВУХ сторон + `lineHasInnerMarker` по границе слова (4 контрпримера гвоздём); (2) `latinResidueCandidates` синхронизирован с #6-lint (caps-утечки адресуемы, гвоздь `TestLatinResidueCandidateCapsSynced`); (3) `chevronSpeechShapeCommaOnly` заморожен → «ДО» 5/1/41/99 из дерева, отвергнутый `Ext` снесён; (4) Р4-потолки inline (`minLatinResidueLen`, `chevronSpeechShape`); (5) пост-чек контракт/комменты честны (B6=строка 81; multi-rune «статистически сильнее», не «word span») + `PostcheckMiss.Demoted` (omitempty, голден-нейтр.) + негатив-гвоздь одно-руновый-не-Han→Confirmed. **k4a воспроизведён маркер-онли (12/12 ключей, 0/0/0/348)**; отчёт §2/§5 — реальные tp вместо 2 ложных, «fn=0 класс-вакуумен» оговорено. Опц. Fix-8 (скоуп `isSpokenChevronLine`) ОТЛОЖЕН вердикт-тройкой (0 инстансов FP-класса в корпусе, загейчен нотой). Приёмка: labels фрозен (k4b 10/1/36/99 · k4_inverse 3/0/1/71 · k2 34/1/6/51 · K6 1/6/0/251 · k4a 0/0/0/348) · build/vet/`-race ./...` · майнер-парити EXACT · голден пере-захват РАЗ = байт-идентичный no-op, маскированный дифф ПУСТ · author≠reviewer (3 refute-агента, 0 подтверждённых багов, мутант-килл негатив-гвоздя исполнен). — **ЛЕНД D39.78** (двухраундовая приёмка оркестратора: 10-агентный адверсариал → 5 CONFIRMED major → постфикс §8 → 3-агентный дельта-раунд: 0 major; голден-маска независимо ПУСТА дважды; остаточные минорки → строка 93, вопросы §6 → строка 92; строка 25 закрыта, 79/89 сужены). **finding-1 (строка 91, Decl мягкознаковых имён) — ЗАМЕР ПРОВЕДЁН (02.08, бэкенд-сессия по указанию владельца в eval-конвенции, $0); РЕШЕНИЕ — владелец+оркестратор.** Отчёт: [archive/reports/SOFTSIGN_DECL_FINDING1_2026-08-02.md](archive/reports/SOFTSIGN_DECL_FINDING1_2026-08-02.md); харнесс `eval/softsign-decl/` (пин-модуль, боевой стеммер), сырьё+2 адверсариал-прогона durable в `~/books/gu-zhenren/softsign-decl/`. ИТОГ: разрыв реален но МАЛ (~4–7 ложных промахов/25 глав на реальном размере чанка, почти весь — ОДИН термин «Винный червь»; «Фан Юань»→0 per-chunk; occurrence-level 259 — код-верифицирован). Вариант **(б) anchor-gated** НЕ закрывает коллизию «Синь/Линь» — ПЕРЕНОСИТ на форму `[титул]+[ь-фамилия]` + не защищает числовой класс («пятого/шестого ранга» ×23); чистота на gu-zhenren = транслит-везение, доказательство **пара-зависимо**, safety НЕ измерена (M4=3 пары/1 книга). Рекомендация: **(а) сид-перечень 1–2 ВИДИМЫХ мягких канон-имён** — данные `decl.forms`, матч generic `containsWholeWord`, **вне ядра Go**, ноль коллизии (честно: ручной труд — майнер морфологию НЕ майнит, declForms руко-писаны); движок (б) — только на КРОСС-ПАРНОМ гейт-замере (≥2 пары), не на одном. Строку 91 и CURRENT-STATE НЕ трогал. Открытые решения — в §«Что решить» отчёта. — **ЛЕНД D39.76 (стопгэп A, $0, ядро Go не тронуто):** в подписанный стенд-сид `~/books/gu-zhenren/guzhenren-seed-v2.yaml` (вне git) дописана МН. парадигма «Винный червь» (ед. обликы «Винный червь» + все 4 «Фан Юань» УЖЕ стояли с 24.07 — нетто-новьё = только мн.); `stemmer_test.go` гвоздь Линь/линию→Линь/линий (эмпир-проба: Stem(линий)=лин дискриминирует, Stem(линию)=лини был ИНЕРТЕН); позитив-гвоздь `memdecl_test.go` «Фан Юаню» принят через forms[] (закрыл PARTIAL D39.75). Приёмка зелёная: build · lang+membank · `tmctl seed-lint` OK (0 fail-loud/0 collision) · голден нетронут (git backend = только 2 тест-файла). ⚠ Уточнение к цифрам замера: coldrun-a — СИДЛЕСС (`coldrun-a/book.yaml`: сид не грузится), потому харнесс мерил ТОЛЬКО стеммер-путь; в сид-прогоне «Фан Юань» обликы уже ловились forms[] → прод-разрыв ещё меньше замеренного. Ждёт подписи владельца на сид-формы (+2 доп. мн. падежа сверх списка владельца: Винным червям / Винных червях — полная парадигма). diff --git a/docs/README.md b/docs/README.md index fa23c25d..8d48a0bb 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,7 +15,7 @@ - `experiments/` — эмпирика полигона: [00-provider-quirks.md](experiments/00-provider-quirks.md) — **читать перед любым вызовом провайдера**; [08-cost-model-v2.md](experiments/08-cost-model-v2.md) — денежная модель; [09-pilot-protocol.md](experiments/09-pilot-protocol.md) — пилот Ф2.5; остальные 01–16 — отчёты закрытых экспериментов (судьба — в баннерах/D-логе). - `research/` — фактура ресёрчей 01–22; у принятых — ревью-шапки, часть тел под ⚠ superseded: **читай баннер прежде содержимого**. Ключевые для навигации: 15 голос · 16 ридер-IDE · 17 внешняя критика · 18 рычаги качества · 19 нарезка · 20 банк-майнинг · 21 обзор транспорта · 22 доменные харнессы. - [PROGRESS.md](PROGRESS.md) — журнал: CURRENT-STATE + **ЕДИНЫЙ БЭКЛОГ** (единственный трекер) + живой хвост хроники. НЕ источник решений. -- Активные хендофф-промты сессий (состав обновляется при каждом лендинге — норма D39.80): [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) (роль/нормы; состояния не дублирует) · [BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md](BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md) (текущий бэкендный) · [POLYGON_PACKAGE4_SESSION_PROMPT.md](POLYGON_PACKAGE4_SESSION_PROMPT.md) (полигон, отложен). +- Активные хендофф-промты сессий (состав обновляется при каждом лендинге — норма D39.80): [ORCHESTRATOR_SESSION_PROMPT.md](ORCHESTRATOR_SESSION_PROMPT.md) (роль/нормы; состояния не дублирует) · [POLYGON_PACKAGE4_SESSION_PROMPT.md](POLYGON_PACKAGE4_SESSION_PROMPT.md) (полигон, отложен). Текущего бэкендного промта НЕТ (мелкая пачка залендена D39.82; очередь — в PROGRESS CURRENT-STATE). Фронт-промт — в чужой зоне `frontend/docs/`. - `archive/` — история ([правила архива](archive/README.md)): закрытые промты (`prompts/`) · отчёты с ревью-шапками (`reports/` — на них ссылаются приёмки) · исполненные арх-доки (`architecture/`) · слайсы хроники `PROGRESS-*.md`. Инструкции оттуда не исполнять. - Диаграммы: [../backend/docs/components.puml](../backend/docs/components.puml) · [../backend/docs/pipeline.puml](../backend/docs/pipeline.puml) — дом рядом с кодом (D39.80), правятся бэкендом одним коммитом с кодом; вручную НЕ рендерить (владелец смотрит PlantUML-расширением VS Code). diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index 1cbb77be..4ca1d9d5 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -1,4 +1,4 @@ -# Журнал решений оркестратора — контракт D1–D39.81 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.07) +# Журнал решений оркестратора — контракт D1–D39.82 (развязки 04.07 · пакеты 09–10.07 · приёмка/качество-первым/пивот/эмпирика 11–12.07 · арх-ресет+стройка пере-прогонного стека 13–19.07) > **⟶ КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто: > ⚠ **Навигация (актуализация 01.08):** карта ниже детально покрывает D1–D39.28; решения D39.29+ живут хронологически в теле файла, **свежая голова — С ХВОСТА** (новые ноты аппендятся вниз). Сводка текущей головы и очередь — CURRENT-STATE в `../PROGRESS.md`. @@ -1243,3 +1243,7 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу ## D39.81 — Фронт открыт: SaaS ратифицирован, процессная модель движка, инвариант непротекания конвейера, бэкенд-работы Ф3 получили строки (02.08) По релею записки фронт-направления; все несущие техутверждения сверены кодом до записи (HTTP-слоя в backend нет — 0 вхождений ListenAndServe; рамка research/16:13 «выхлоп = экспорт-артефакты, НЕ веб-сервер; API/SSE — Ф3» дословна; `pipeline/ratelimit.go:11` — гарды per-процесс, mistral ~48% отказов под параллелизмом; `store.go:43` EXCLUSIVE flock + `OpenReadOnly`-пути без flock; банк-пауза `--verify-bank` построена). Ратифицировано: **(1) Продукт = SaaS**, не локальное приложение (решение владельца 02.08); зоны `frontend/` (фронт-сессия на моках, промт `frontend/docs/FRONTEND_SESSION_PROMPT.md`) и `platform/` (control plane) — живые, роль «Платформа» в CLAUDE.md; натив-прицел V0-п.26 снят этим решением (реестр ПТ-23). **(2) Инвариант непротекания конвейера:** интерфейс не раскрывает модели/стадии/внутреннюю терминологию — пользователь видит «загрузка → разбор → перевод → подпись банка → финал → готово»; API отдаёт продуктовые понятия, слой перевода внутренних вердиктов обязателен (сегодняшний read-model с flag_reason/стадиями наружу не выдаётся); ревью-вопрос — зеркало общности: «сменится стадия конвейера — придётся ли править фронт?» (реестр ПТ-33). **(3) Процессная модель:** движок = процесс-на-прогон (EXCLUSIVE flock — инвариант, не ограничение); СЕРВЕР В backend НЕ ПИШЕТСЯ — HTTP/SSE и пользователи/квоты/очередь живут в `platform/`; read-API строится поверх готовых `OpenReadOnly`-путей. **(4) Строки:** НОВЫЕ 95 «контракт API v0 + продуктовый словарь статусов» ($0; ЕДИНСТВЕННАЯ ранняя вставка — фронт на моках, без пришпиленного контракта моки и API разойдутся классом «док↔код»; двухфазный поток вывести из готового `--verify-bank`, не проектировать) · 96 «HTTP/SSE + сервисная обвязка в platform» (Ф3, после 95) · 97 «глобальный брокер рейт-лимитов» (гейт «до второго параллельного пользователя»); ПЕРЕВЕСЫ: 49 annot-половина D15.2 → «скоро» (annot-v1 = источник замечаний фронта, критический путь подключения) · 94 → «к подключению фронта». **(5) Курс НЕ пере-упорядочен:** движковая очередь (смолпак → ре-проба 74 → добор идеала → ja→ru → МАСШТАБ) стоит — фронт бэкенда не ждёт. Поправки записке при приёмке: реестр требований, который она предлагает завести, УЖЕ построен (D39.80, `product-requirements.md`); H7/H8/H10 уже несла строка 94; ETA — строка 54; H-требования сверены — статусы записки совпали с реестром (02.08.2026, оркестратор №9, по релею владельца «надо записать в планы»). ✅ + +## D39.82 — Мелкая пачка хвостов принята и залендена; DC7 пере-гейчен сид-покрытием с домом канона в book.yaml (02.08) + +Отчёт: `archive/reports/SMALLPACK_TAILS_2026-08-02.md` (ревью-шапка). Исполнено строками 93/89/53/83 + решения D39.79: **A** — 8 хвостов ревью чекеров (whole-word inner-marker вето + гвоздь · recall-потолок в контракт · негатив-ассерт Demoted=false, мутант убит · честные комменты); **B** — ведро D добито (isHeaderSeparator = !isHeaderContentRune, комплементарность доказана приёмкой полным перебором 1 114 112 рун · мёртвый freqStratum удалён, парити EXACT), ведро B — все 6 на носителях (строки 35/81); **C** — gofmt чист · editor-mono.md DORMANT hash-нейтрально · дубль-ключ пар-файла fail-loud (`putUniqueDC`) · архео D39.4/D23.4/D28.3(б) диспозициями; **D/F4** — `tmctl backup` (integrity_check + VACUUM INTO `~/books/<книга>/backups/`) + боевой pre-flight в `run()`-диспетчере (ноль магических строк провайдеров; гвоздь «fake-путь бэкапов не создаёт»); **E** — Q1 канон «класс X» подтверждён read-only (живые носители чисты); Q2 兩 в `shichen_re`; Q4 register_neg+жанр-msg → книжный слой (`book.yaml register_blocklist` → union в DC6; BriefHash с omitempty — голден `c2021b1b…` неизменен, приёмка пере-вывела hash из фикстуры независимо; пар-файл без register легален; «вторая книга другого жанра — 0 ложных DC6, 0 правок Go» запинено тестом). Приёмка: батарея пере-прогнана независимо (build/vet/race · парити EXACT · голден-фикстура не тронута ни байтом · labels 6/6 фрозен-ячеек · DC7-замер пере-выведен 25/93/150) + 5-осевой refute-воркфлоу: **0 блокеров, 0 major против пакета**. **DC7: стоп сессии ПОДТВЕРЖДЁН, рекомендация ИСПРАВЛЕНА вторым рубежом** — класс в значительной мере уже покрыт mempostcheck+сид-v2 (грейды = approved-записи с decl-формами; «разряд» в сидовом прогоне = промах dst), а labels-финалы — прогоны до/вне сид-канона; ратифицировано: дом канона грейда = КНИЖНЫЙ конфиг (посылка D39.79 «данные в dc-checkers.txt» исправлена — пар-паковый DC7 ложно флагнул бы вторую книгу), стройка — ТОЛЬКО по остатку девиаций на первом сидовом прогоне добора идеала (строка 98). Минор приёмки: `redrive --dry-run` обходит pre-flight при мутирующем store.Open (миграции+recover) → хвост в суженную строку 93. `CheapGateVersion` НЕ бампнут; `LangpackVersion` двинут байтами пар-пака (ок, D39.63). Строки 89/53/83 ЗАКРЫТЫ; 93 сужена до остатков; НОВАЯ 98. Стенд-данные вне git: `coldrun-a/book.yaml` (register_blocklist) · `dc7-measure/` замер. Промт — в архив с баннером (02.08.2026, оркестратор №9). ✅ diff --git a/docs/BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md b/docs/archive/prompts/BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md similarity index 96% rename from docs/BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md rename to docs/archive/prompts/BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md index 86f75763..0f64dfd4 100644 --- a/docs/BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md +++ b/docs/archive/prompts/BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md @@ -1,5 +1,7 @@ # Промт бэкенд-сессии: МЕЛКАЯ ПАЧКА ХВОСТОВ (строки 89 · 93 · 53 · 83 + решения D39.79; $0) +> ⚠ **АРХИВ (02.08.2026): исполнен и ПРИНЯТ — D39.82.** Отчёт с ревью-шапкой приёмки: [../reports/SMALLPACK_TAILS_2026-08-02.md](../reports/SMALLPACK_TAILS_2026-08-02.md). Инструкции отсюда не исполнять. + **Выдан 02.08.2026, оркестратор №9; санкция владельца 02.08 («давай сделаем») + решения §6 пакета-чекеров, принятые делегированием владельца и ратифицированные D39.79.** Ты — бэкенд-сессия TextMachine. Зона: `backend/` (+ `configs/langpacks/`, стенд-данные `~/books/gu-zhenren/`) + свой отчёт в `docs/archive/reports/`. НЕ коммитить — лендит оркестратор. **Первый деливерабл — эхо-блок ≤10 строк** (первым сообщением ДО работы: как понял скоуп/стоп-точки/$0/зону; продублируй шапкой отчёта; подтверждения не жди — СТОП только в конце). Денег НОЛЬ: ни одного вызова провайдеров, только код/тесты/данные/фейк-провайдер. ## §0. Рамка @@ -18,7 +20,7 @@ ## §2. Карта чтения (по якорям, целиком доки НЕ читать) -CLAUDE.md → этот промт → по ходу пунктов: ревью-шапка и §8 отчёта [`archive/reports/CHECKER_PACKAGE_2026-08-02.md`](archive/reports/CHECKER_PACKAGE_2026-08-02.md) (контекст хвостов §3-A) · карта §3 `docs/archive/reports/GENERALITY_RESEARCH*` (вёдра B/D для §3-B) · `docs/architecture/12-go-style-notes.md` (норматив общности §0). D-корпус целиком НЕ читать. +CLAUDE.md → этот промт → по ходу пунктов: ревью-шапка и §8 отчёта [`archive/reports/CHECKER_PACKAGE_2026-08-02.md`](../reports/CHECKER_PACKAGE_2026-08-02.md) (контекст хвостов §3-A) · карта §3 `docs/archive/reports/GENERALITY_RESEARCH*` (вёдра B/D для §3-B) · `docs/architecture/12-go-style-notes.md` (норматив общности §0). D-корпус целиком НЕ читать. ## §3. Пункты (каждому — вердикт-тройка в отчёте) diff --git a/docs/archive/reports/SMALLPACK_TAILS_2026-08-02.md b/docs/archive/reports/SMALLPACK_TAILS_2026-08-02.md new file mode 100644 index 00000000..c975d6fe --- /dev/null +++ b/docs/archive/reports/SMALLPACK_TAILS_2026-08-02.md @@ -0,0 +1,163 @@ +# Отчёт: МЕЛКАЯ ПАЧКА ХВОСТОВ (строки 93·89·53·83 + решения D39.79; $0) + +> **✅ ПРИНЯТ И ЗАЛЕНДЕН (оркестратор №9, 02.08.2026, D-номер лендинга: D39.82).** Приёмка исполнением: батарея пере-прогнана независимо — build/vet/`-race ./...` зелёные · парити EXACT `-count=1` · голден зелёный и фикстура в git НЕ тронута ни байтом · labels 6/6 фрозен-ячеек воспроизведены (вкл. фрозен-ДО#3 5/1/41/99) · gofmt пуст · DC7-замер пере-выведен скриптом (25/93/150 совпали). Второй рубеж — 5-осевой refute-воркфлоу (Q4-общность/хеш · F4-обход · A-фиксы · B-комплементарность · посылка DC7-стопа): **0 блокеров, 0 major ПРОТИВ пакета** — omitempty-байт-идентичность доказана пере-выводом brief_hash из фикстуры (`c2021b1b…` воспроизведён, populated re-пинит), комплементарность header-рун — полным перебором 1 114 112 рун, мутант A3 убит ровно новым ассертом, CAND зовёт прод-символы. Находки поверх: **(major CONFIRMED — против РЕКОМЕНДАЦИИ §5-E1, стоп сессии ВЕРЕН)** DC7-класс в значительной мере уже покрыт mempostcheck + сид-v2 (грейды 甲/乙/丙/丁等 = approved-записи с decl-формами «класса/классу…»; сидовый прогон флагнёт «разряд» промахом dst) — финалы labels-корпуса до-/вне-сид-v2-канона, потому 25/93 не мерят остаток; **DC7 пере-гейчен: замер ОСТАТКА на первом сидовом прогоне добора идеала, дом канона book.yaml ратифицирован (строка 98)**. **(minor)** `redrive --dry-run` обходит pre-flight, а `store.Open` МУТИРУЕТ SPOF-файл (миграции + recoverReservations) — коммент «mutates nothing» неверен → хвост в строку 93 (+stat-скип точной формой fs.ErrNotExist). Ноты-конвенции (дефис-частица «в уме-то» вне whole-word-вето — документированный трейд; CAND-прегейт шире прод-гейта dash+dialogueShape — корпус-нейтрально; ja-формулировка ведра B сжата против двухветвевой карты) — приняты без носителей. + +**Дата:** 02.08.2026 · **Сессия:** бэкенд · **Промт:** `docs/BACKEND_SMALLPACK_TAILS_SESSION_PROMPT.md`. +**Зона:** `backend/` (+ `configs/langpacks/zh-ru/`, стенд `~/books/gu-zhenren/`) + этот отчёт. **Деньги:** $0 — ни одного вызова провайдеров; только код/тесты/данные/фейк-провайдер. **Статус:** НЕ закоммичено — лендит оркестратор. **D-номер лендинга проставит оркестратор.** **СТОП в конце — приёмка оркестратора.** + +## ЭХО-БЛОК (как понят скоуп) + +1. **Скоуп:** ~20 хирургических хвостов бэклог-строк **93·89·53·83 + D39.79**. Каждому пункту — вердикт-тройка (а: посылка · б: нужность на данных · в: форма/исход) ДО кода; «не чинить, закрыть с обоснованием» — легитимный исход; замер бьёт по посылке → СТОП+пинг. +2. **$0:** денег ноль, вызовов провайдеров нет. +3. **Зона:** `backend/` + `configs/langpacks/` + стенд-данные `~/books/gu-zhenren/` + отчёт + краткий итог в PROGRESS §Бэкенд. Не тронуто: `START_PROMT.MD`, `frontend/`, `platform/`, бэклог-таблица, CURRENT-STATE. +4. **Инварианты:** build/vet/`-race` зелёные · майнер-парити EXACT · labels-фриз (k4b 10/1/36/99 · k4_inverse 3/0/1/71 · k2 34/1/6/51 · K6 1/6/0/251 · k4a 0/0/0/348 · фрозен-ДО#3 5/1/41/99) · **голден НЕ движется** (`CheapGateVersion` НЕ бампнут; brief_hash `c2021b1b…` неизменен). +5. **Стоп-точки:** не коммитить; F4-дискриминатор боевого пути БЕЗ магических строк провайдеров в Go; DC7 при >~50 строк Go или бое по посылке → СТОП+пинг. + +--- + +## §1. Инварианты приёмки — числами + +- `go build ./...` · `go vet ./...` — **зелёные**. +- `go test -race ./...` — **все пакеты зелёные**. +- **Майнер-парити EXACT** (`TM_MINER_PARITY=1 go test ./internal/miner/ -run Parity`) — после удаления `freqStratum` и коммента `minerPackVersion` выходы НЕ двинулись. +- **Labels-фриз** (`TM_CHECKER_LABELS=1 go test ./internal/checks/ ./internal/membank/ -run Label`): **k4b 10/1/36/99 · k4_inverse 3/0/1/71 · k2 34/1/6/51 · K6 1/6/0/251 · k4a 0/0/0/348 · фрозен-ДО#3 5/1/41/99** — ни одна ячейка не двинулась (`assertBaseline` хард-ассертит все классы, зелёный). DC7 НЕ построен (см. §5-E1), новых классов замера не добавлено. +- **Голден** (`go test ./internal/pipeline/ -run TestGoldenDeterminism`) — **зелёный, НЕ двинулся**. `brief_hash` = `c2021b1b5e6c…cab0df` (замороженный layout D39.26 сохранён: `RegisterBlocklist` в BriefHash несёт `omitempty`, книга без него байт-идентична). `CheapGateVersion` НЕ бампнут. Байты пар-пака (`dc-checkers.txt`) двигают `LangpackVersion` — на этой фазе ок (перекупок нет, D39.63); голден-фикстура пар-пак НЕ грузит и стоит. +- `gofmt -l .` из `backend/` — **пусто**. + +--- + +## §2. §3-A — строка 93 (хвосты двухраундового ревью чекеров) + +Легенда вердикт-тройки: **(а)** посылка · **(б)** нужность на labels · **(в)** форма/исход. + +**A.1 substring-вето → whole-word** (`checkers.go isSpokenChevronLine`). (а) ВЕРНА: инвертированный `strings.Contains` по `innerMarker` глушил spoken по подстроке — «в уме» ⊂ «в умении» гасил РЕАЛЬНЫЙ mixing-клэш (данные `target-ru.txt:58` содержат `inner_marker в уме`). (б) 0 инстансов в корпусе (labels стоят). (в) **Сделано:** вето переиспользует `c.lineHasInnerMarker` (→ `containsWholeWordPhrase`), whole-word; verb-проба остаётся substring (её whole-line-цитатный residual — отдельный DEFERRED-гейт D39.78). Гвоздь `TestChevronMixingWholeWordInnerMarker` (ёлочная реплика с «в умении» + тире-строка → mixing фаерит; whole-word «про себя» всё ещё вето). + +**A.2 recall-трейд отсечения — в контракт** (`cheapgates.go speechAttribution`). (а) ВЕРНА. (в) **Сделано:** дописан RECALL-CEILING в док-коммент — маркер ПОСЛЕ внутренней запятой/многоточия внутри атрибуции («— …гу, — сказал он, про себя ругаясь») невидим = потолок ПРАВИЛА (двусторонний cut), не данных; симметрично Р4-записи #3. Потолок В КОНТРАКТ, не пойнтером. + +**A.3 негатив-ассерт Demoted** (`mempostcheck_test.go`). (а) ВЕРНА: мутант `m.Demoted=true` в default-ветке `Postcheck` (ambiguous-мисс) переживал сьют. (в) **Сделано:** `TestAmbiguousMissNotDemoted` — auto-статус (→ Ambiguous), 2-руновый Han-ключ (не #10-демоция) → default-ветка → Unverified с `Demoted=false`, `Disp="ambiguous"`. Мутант убит. + +**A.4 stale-коммент** (`checks/repair.go LatinResidueCount`). (а) ВЕРНА: `lintLatinResidue` считает токены ЛЮБОГО регистра после #6 (caps-reject снят), коммент лгал «lowercase». (в) **Сделано:** коммент честен — «any case; BANK/Cultivation count alongside lowercase». (Не перепутан с `pipeline/repair.go:349`.) + +**A.5 CAND k4_inverse = опровергнутый вариант** (`labelcandidates_test.go`). (а) ВЕРНА: CAND мерил whole-line-substring (`hasInnerMarker`), отвергнутый ревью, а лог читался как прод-числа (на корпусе совпадает 3/0/1/71 — divergence адверсариальна, не размечена). (в) **Сделано (образец фикса-3):** CAND = боевое правило (attribution-scoped `speechAttribution`+`lineHasInnerMarker`); отвергнутый whole-line оставлен ЯВНО помеченным `REJECTED` (helper `hasInnerMarkerWholeLine`), с нотой «идентичен на корпусе, потому FP взял адверсариал, не labels». Лог не врёт. + +**A.6 конвенции «..»/thin-space** (`cheapgates.go`). (а) ВЕРНА (два расхождения код↔коммент). (в) **Сделано, БЕЗ смены поведения (labels-фриз, §4):** `sentenceFinalBefore` — коммент приведён к коду («TWO+ ascii-точки, инспектируются последние две; «..» и «...» проходят»); `isInlineSpace` — одна оговорка в коммент, что thin-space U+2009 сознательно НЕ включён (унаследованная HEAD-конвенция, известное непокрытие). Код «..»/thin-space не тронут. + +**A.7 кана-оговорка** (`mempostcheck_test.go`). (а) ВЕРНА: «Han alone lacks segmentation» неточно — кана ТОЖЕ не boundary-checked (`memory.go:536` дословно «Han/kana are NOT boundary-checked»). (в) **Сделано:** переформулировано честно — демоция Han-only = ратифицированный СКОУП до B6 (строка 81), одиночная кана = равно слабая улика, но демоция на неё пока не расширена (не «кана сегментирована»). + +**A.8 target-алиасы пост-чек-матча** (`mempostcheck.go dstFormPresent`). (в) **НЕ строить:** одна NB-строка в контракт — множество = база dst ∪ decl-формы; альтернативный target-рендер (синоним/прозвище) НЕ матчится, это СИД-концерн (сидить как decl-форму/алиас), не код; на той же ноге, что inflection_gap. + +Из строки 93 НЕ втащено (по промту): строгий OffLanguage (36а, deferred) · 分之-разметка (полигон). + +--- + +## §3. §3-B — строка 89 (остаток ведра D + сверка ведра B) + +### Ведро D (карта §3 GENERALITY_RESEARCH §3.4) — построчный вердикт + +| Позиция (карта) | Диспозиция | +|---|---| +| `render.go:89,485` `⟨проверить⟩` глифы | **чинится здесь (коммент):** операторские English-строки НАЗЫВАЮТ ru-маркер (не wire-строка модели — канон в `injection.txt`/`embedded.UnverifiedMarker`); ограниченная утечка (не-ru цель показала бы ru-глиф в help-тексте) заанкорена комментом; плюмбить пер-target-маркер в две help-строки непропорционально. `status.go:98` и др. — комменты (не утечка). | +| `dc-checkers.txt:7-17` `cjk_numeral` третья копия | **не нужно здесь:** дублирование данных = ведро-A класс (отдельно); пар-пак ТРЕБУЕТ `cjk_numeral` непустым как parse-полноту; внутрифайловый дубль-КЛЮЧ закрыт §3-C (`putUniqueDC`). | +| `pipeline-c2.yaml:36` `prompt_override` | **не нужно (booked):** бэклог-строка 42. Документирован комментом :35. | +| `miner.go:52` вес palladius + `miner_patterns.go:19` `PackVersion` | **PackVersion — чинится здесь (коммент):** поле write-only (0 чтений по репо, не фолдится в снапшот/парити — сверено); «-universal-» = channel-schema слой, НЕ универсальность движка (surname/Palladius zh-shaped); значение НЕ трогал (unread version string не churn'ю; удаление dead-поля = альтернатива оркестратору). **palladius-вес — не чинить:** коммент уже честен («retained for parity; off in default B»), зафиксировано. | +| `miner_substrate.go:123` dead `freq_stratum` | **чинится здесь (удалено):** 0 вызовов по репо (unexported), удаление парити-безопасно; парити EXACT после. | +| `chunker.go:222` дубль `isHeaderSeparator` | **чинится здесь (консолидировано):** `isHeaderSeparator` (ingest.go) = точное булево ДОПОЛНЕНИЕ `isHeaderContentRune` (chunker.go); переписан как `!isHeaderContentRune(r)` — один источник рун-классов, behavior-preserving (chunk+golden зелёные). Третье имя `isHeadingSeparator` (chunker.go) — ОТДЕЛЬНЫЙ узкий whitelist пунктуации (НЕ та пара); кросс-ref-комменты добавлены обеим, чтоб не сравнили не ту пару. | +| `cheapgates.go:56,125,130`+`langpack.go:73,135,822` 6 указателей на `checkers_zh_ru.go` | **сделано (D39.78):** grep пуст. | +| `checkers.go:24` ложная шапка (register_neg = пар-таблица) | **сделано (D39.78) → и обновлено здесь E3:** после Q4 шапка честно говорит «DC6 register blocklist NOT here — book property». | +| `prompts/zh-ru/*.md` проводной формат прозой | **не нужно (booked):** бэклог-строка 36г. | + +### Ведро B (карта §3.2, 6 «пар-модуль без гейта») — сверка носителей + +| Позиция | Живо/закрыто · носитель | +|---|---| +| `miner_substrate.go:74` генератор N-грамм (scriptio continua) | ЖИВО · носитель **строка 35** (generic не-CJK майнер). | +| `langpack.go:772` 7 обязательных Палладий-категорий | ЖИВО (жёсткий блокер данных: ja-пак не грузится) · носитель **строка 81** (ja-преп, Поливанов-валидатор) + строка 35. | +| `memseed.go:510` японский furigana-классификатор | ЖИВО (мёртв на практике) · носитель **строка 81** (ja-преп). | +| `miner_patterns.go:183` порядок имени (фамилия-префикс) | ЖИВО · носитель **строка 35**. | +| `miner_alias.go:93` контейнмент как алиас-признак | ЖИВО · носитель **строка 35**. | +| `miner_palladius.go:115` «слово = макс. ран букв» | ЖИВО · носитель **строка 35**. | + +**Живого без носителя НЕТ → пинга не требуется.** Все 6 несут майнерскую zh-привязку (строка 35) либо ja-специфику (строка 81); фаза-2/чекер-пак майнер-зону не трогали. + +--- + +## §4. §3-C — строка 53 (мелкие хвосты качества) + +- **gofmt:** `internal/llm/llm.go` + `pipeline/miningstop_join_test.go` + `terminology/classify_test.go` + `terminology/series_test.go` отформатированы (чисто форматирование). `gofmt -l .` из `backend/` теперь **пусто**. +- **`editor-mono.md` DORMANT.** (а)(б) ВЕРНЫ (спит — монолингв-вариант под пилот D13.1-арм, не в живом пайплайне; пометки в файле не было). (в) **Сделано:** `` в шапку файла — читатель видит статус, но модель НЕ видит (strip 14b, `stripPromptComments`), и хеш НЕ движется (SHA над comment-free формой, `TestPromptSHAIsOverTheCanonicalForm`). **Боевого PromptSHA256 правка НЕ двигает** — файл не грузит ни один живой конфиг (только `runner_memory_test.go` читает сырьё; зелёный). Боевой `editor.md` НЕ тронут. +- **Внутрифайловый дубль ключа пар-файла → громкая ошибка.** (а) ВЕРНА: `parseDCCheckers` тихо перезаписывал дубль-ключ в 5 map-категориях (Numeral/RuHours/Patterns/Messages/Ratios). (в) **Сделано:** дженерик-гард `putUniqueDC[K,V]` (least-mechanism, идиома repo `queryAll`/`retryLoop`) — дубль-ключ = fail-loud «duplicate key». `register_neg` = список (не ключ) → дубли ТЕРПИМЫ (зафиксировано тестом). Тест `TestParseDCCheckersRefusesIntraFileDupKey` (5 категорий + register_neg-толерантность). Реальный пак грузится (0 дублей). +- **Ledger-очередь LOW/NOTE (D39.4) и остаток D23 п.4 / D28 п.3(б).** Диспозиции (архео по D-номерам, кода не требуют): + - *D39.4 ledger-очередь LOW/NOTE:* припаркованная 14 LOW + 14 NOTE хвост пака-1.5 адверсариала (Retries-снапшот · pair-fail-loud · trustGateEvent · golden-глосс-ячейка · пунктуационные огрызки); **не чинить** — легитимно отложено, носитель = D39.4/D39.5 (пересекается с живыми строками 43/78); эта пачка их не втягивает (ledger/edge-полиш вне скоупа). + - *D23 п.4:* `errTail` рун-граница-тест — **moot** (символ `errTail` в репо ОТСУТСТВУЕТ; вытеснен рефактором `BilledDecodeError`/`maxResponseBytes`); retryLoop лог-ассерты — **уже сделано** (`httpllm_test.go:261-293`, тег `(D23.4)`); AMBIGUOUS>0 golden — **уже сделано** (`golden_test.go:56` документирует как опциональное; `ambiguous`-счётчик эмитится); snapshotID-payload-тест/kill9 3→5 — **опц., не чинить**. + - *D28 п.3(б):* provider_local MaxTokens-override-бьёт-флор нота — **уже сделано** (`escalation.go:28-36`, тег `⚠ LATENT INTERACTION (D28.3б, note not a defect)`). + +--- + +## §5. §3-D (F4) и §3-E (D39.79) + +### D — F4: бэкап + integrity_check (строка 83) + +(а) ВЕРНА (SPOF: тихая потеря подписанного банка/подписей). (б) продукт-требование. (в) **Сделано:** +- `internal/store/backup.go` — `BackupSQLite(dbPath, backupDir, stamp)`: `PRAGMA integrity_check` → `VACUUM INTO backupDir/.db` (SQLite-blessed online-бэкап). Не-«ok» → LOUD, бэкап НЕ пишется; missing-source → loud; overwrite → отказ. `IntegrityCheck(dbPath)` — read-only хелпер. Юнит: `store/backup_test.go` (валидный restore-point openable как store; corrupt-source отказ без файла; missing+overwrite отказы). +- `cmd/tmctl/backup.go` — верб `backup` (VACUUM+integrity, печатает путь) + `preflightBackup` (боевой pre-flight: missing DB = свежая книга, no-op; integrity-провал = LOUD отказ старта). Durable-дом строго `backups/` рядом с ProjectDB (под `~/books`, «персист, не scratch»). +- **Дискриминатор боевого пути — вердикт-тройкой, с констрейнтом «без магических строк провайдеров в Go»:** pre-flight ВЕШАЕТСЯ на `run()`-диспетчер (`case "translate"` / `case "redrive"` при не-dry-run), НЕ внутри `translate()`/Runner. Тесты фейк-провайдера зовут `translate()`/Runner НАПРЯМУЮ (golden, rebill_cli) → в `run()` не входят → бэкапов НЕ создают. **Ноль магических строк провайдеров в прод-Go** — обход чисто структурный. Гвоздь `TestFakeTranslatePathCreatesNoBackup` (фейк-`translate()` → нет `backups/`), `TestPreflightBackup{CreatesRestorePoint,SkipsFreshBook}`. Голден (Runner-путь) бэкапов не создаёт — подтверждено (голден зелёный, тестов-файлов не прибавилось). + +### E1 — Q1 канон грейда «класс X» + замер нужности DC7 + +**(а) Факт-фиксация носителя канона (read-only, БЕЗ правок прогон-БД) — ПОДТВЕРЖДЕНО:** +- Подписанный сид `guzhenren-seed-v2.yaml`: `dst`-поля ЧИСТЫ от «разряд» (7 вхождений — все в `note:`-комментариях: 3 биографических + 4 грейд-определения `[reseed:approved D38.5]`, где ЯВНО зафиксирован канон **«грейд 甲等→«класс А»… Схема 甲乙丙丁 = класс А>Б>В>Г»**, «было "разряд", draft→approved»). Живой носитель канона = «класс X». +- `coldrun-a/BANK-FULL.tsv` (151 строка, 6 «разряд») = экспорт майнер-ЧЕРНОВИКОВ на подпись — «разряд» ОЖИДАЕМ, канона не несёт. +- Банк coldrun-a пуст (встал на банк-стопе); «разряд»-draft-ряды — только в замороженных исторических прогон-БД (не трогал). +- **«Разряд» в ЖИВОМ носителе (сид/эталонный конфиг) НЕ найден → пинга не требуется.** Канон «класс X» зафиксирован. + +**(б) $0-замер нужности DC7** (боевые ФИНАЛЫ labels-корпуса, `labels/raw/corpus.jsonl`; скрипт `~/books/gu-zhenren/dc7-measure/measure_grade_render.py` — durable, воспроизводимо): +- 91 юнит; 55 несут грейд-терм в источнике (甲/乙/丙/丁等). +- **ДЕВИАЦИИ: 25 юнитов / 93 вхождения** рендерят грейд как «разряд X» (потолок дефекта ≠ канон «класс X»); канон «класс X» = 150 вхождений. **≥1 реальный дефект.** + +**ДИСПОЗИЦИЯ DC7 — построй по замеру, НО двойной стоп-констрейнт сработал → СТОП+ПИНГ с дизайном (DC7 НЕ построен здесь):** +- **Посылка «данные в dc-checkers.txt» бьётся мандатом общности.** Канон-хедворд грейда («класс» vs «разряд» vs «ранг») — это ПЕР-КНИЖНЫЙ рендер-выбор (сид `[reseed:approved D38.5]`), НЕ числовой факт как DC1 (时辰=2h, универсально). DC7 с «класс» в ПАР-паке ложно флагнёт ВТОРУЮ zh→ru книгу другого жанра — прямое нарушение расширения D39.79 «вторая книга без ложных флагов и без правки Go». Правильный дом = **КНИЖНЫЙ конфиг** (ровно куда E3 переносит register_neg этой же пачкой). Строить DC7 с dc-checkers.txt-домом = немедленно противоречить E3. +- **Размер.** Корректный минимальный DC7 (новый book-config канал грейд-канона + parse + lint + wire + result-поле + тест) = новый класс чекера ≈50–60 строк Go — на/выше порога «>~50 строк → СТОП+пинг с дизайном». +- **Оба стоп-условия сработали.** Не строю формальный артефакт с утечкой книго-канона в пар-слой. **Рекомендация оркестратору:** DC7 санкционировать отдельным касанием — грейд-канон в book.yaml (как register_blocklist), generic-алгоритм консистентности-к-канону, observability-класс; замер (25/93 девиаций) оправдывает; триггер (первая книга с живой грейд-серией) наступил. + +### E2 — Q2: 兩 довершить консистентно + +(а) ВЕРНА (兩 в `cjk_numeral`/`cheng_re`, но НЕ в числовой группе `shichen_re`). (в) **Сделано:** `shichen_re` группа `[0-9一二两三…]` → `[0-9一二两兩三…]` (兩 после 两), консистентно с `cheng_re` (обе группы уже несли 两兩 — НЕ тронуты) и `cjk_numeral` (兩 уже был). Probe-ЛИТЕРАЛЫ (时辰/千万/数十万) НЕ тронуты. NB-коммент границы в файл: «traditional = numeral-классы; паттерн-литералы simplified до первой traditional-книги (D39.79)». Замер: 0 движения labels (корпус simplified; нет 兩个时辰). Двигает `LangpackVersion` (ок, D39.63). + +### E3 — Q4: register_neg → книжный слой + +(а) ВЕРНА (терем-список = target-ru/жанро-свойство, «xianxia» в msg = жанр-литерал — оба в ПАР-паке). (в) **Сделано, DC6-алгоритм БЕЗ смены семантики:** +- **Дом = книжный конфиг:** новое поле `book.yaml register_blocklist` (config/book.go), та же lower-fold+sort дисциплina, что `StyleAllowlist` → `CheapGateConfig.RegisterBlocklist` → DC6. **Фолд в BriefHash с `omitempty`** (verdict-affecting → правка re-пинит; книга БЕЗ поля байт-идентична → **замороженный layout D39.26 сохранён, golden brief_hash `c2021b1b…` неизменен**). +- **DC6 = union** `c.registerNeg` (опциональный пар-уровень; zh-ru теперь шлёт 0) ∪ book `RegisterBlocklist`; detail = **generic English в Go** (пар-пак жанро-несущего msg больше не несёт). Общность: НОЛЬ книго/жанро-литералов в Go (грепом чисто). +- **Опциональность (оба):** `parseDCCheckers` больше не требует непустой `register_neg`; `mustPairMessages` больше не требует `dc6_register` — пар-файл без register-таблиц легален. +- **Данные:** терем-парадигма (6 форм) переехала в `~/books/gu-zhenren/coldrun-a/book.yaml` (последний боевой) + пример-секция в `backend/example/book.yaml` (шаблон). `dc-checkers.txt` register_neg-строки + `dc6_register`-msg удалены. Исторические прогон-конфиги и голден-фикстура `internal/pipeline/testdata/golden/book.yaml` НЕ тронуты. +- **Тесты на конфиг-источник:** `TestDC6RegisterLexicon` (book-blocklist путь + inert-без-blocklist = second-book invariant); `TestDC6PairLevelRegisterOptional` (опциональный пар-union член); `checkers_pairdata_test`/`TestNoPackStaysInert` обновлены под новую сигнатуру; corrupt-pack «no message» переключён на `dc2_shushiwan`. +- **Ревью-вопрос приёмки «вторая книга zh-ru другого жанра — НОЛЬ ложных DC6 и НОЛЬ правок Go?» → ДА** (нет register_blocklist → DC6 инертен; тест это пинит). + +--- + +## §6. Чего НЕ делал (по промту) / что НЕ построено + +- `CheapGateVersion` НЕ бампнут · боевой `editor.md` НЕ тронут · thin-space/«..»-ПОВЕДЕНИЕ кода не менял (только комменты) · `minerPackVersion` значение не переименовано · Р2/Р4-потолки — В КОНТРАКТ, не пойнтером · провайдеров не звал. +- **DC7 НЕ построен** — стоп+пинг с дизайном (§5-E1): книго-канон-дом + >~50 строк. + +--- + +## §7. Git-список тронутого (только своё; НЕ закоммичено) + +**Новые:** `internal/store/backup.go` · `internal/store/backup_test.go` · `cmd/tmctl/backup.go` · `cmd/tmctl/backup_test.go`. +**Изменённые (`backend/`):** `cmd/tmctl/main.go` · `cmd/tmctl/render.go` · `configs/langpacks/zh-ru/dc-checkers.txt` · `example/book.yaml` · `internal/checks/{cheapgates,checkers,repair}.go` · `internal/checks/{checkers_pairdata,checkers_zh_ru,labelcandidates}_test.go` · `internal/chunk/{chunker,ingest}.go` · `internal/config/book.go` · `internal/lang/langpack.go` · `internal/lang/langpack_test.go` · `internal/llm/llm.go` (gofmt) · `internal/membank/mempostcheck.go` · `internal/membank/mempostcheck_test.go` · `internal/miner/{miner_patterns,miner_substrate}.go` · `internal/pipeline/chunkrun.go` · `internal/pipeline/miningstop_join_test.go` (gofmt) · `internal/terminology/{classify,series}_test.go` (gofmt) · `prompts/zh-ru/editor-mono.md`. +**Стенд-данные (вне git, `~/books/gu-zhenren/`):** `coldrun-a/book.yaml` (register_blocklist) · `dc7-measure/measure_grade_render.py` (замер). + +--- + +## §8. Самопроверка исполнением + адверсариальное author≠reviewer + +**Самопроверка исполнением (мандат 12.07):** каждый фикс замерен ДО отчёта — A-правки на labels-харнессе (все ячейки §1 неизменны, `assertBaseline` зелёный), новые тесты прогнаны (`TestChevronMixingWholeWordInnerMarker`, `TestAmbiguousMissNotDemoted`, `TestParseDCCheckersRefusesIntraFileDupKey`, F4-батарея, DC6-конфиг-тесты); E3 golden-нейтральность подтверждена независимо (brief_hash `c2021b1b…` побайтно); F4-дискриминатор проверен исполнением (фейк-`translate()` → нет `backups/`); DC7-замер воспроизводим durable-скриптом. + +**Адверсариальный author≠reviewer (6-агентный refute-biased воркфлоу по СВОИМ правкам):** 6 измерений (A-корректность · E3-общность · F4-дискриминатор · инвариант-честность · общность-сweep B/C · E2+dup-key), каждый ГОНЯЛ проверки исполнением. **Итог: 0 блокеров, 0 major.** 4 измерения — чисто (F4/инварианты/B-C/E2 — golden/labels/parity/дискриминатор/dup-key подтверждены запуском независимо). 2 CONFIRMED low-severity (диагностика/коммент, тема самой пачки) — **исправлены ДО отчёта:** +1. **(minor→fixed)** `lintRegisterLexicon` detail безусловно называл «the book's register_blocklist», хотя union фаерит и на пар-уровневом `c.registerNeg` (дормант для zh-ru, но поддержан и тестирован `TestDC6PairLevelRegisterOptional`) → на будущей паре с register_neg диагностика указала бы не тот конфиг. Фикс: source-neutral формулировка. +2. **(nit→fixed)** коммент `containsWholeWordPhrase` приписывал `isWordRune`-границу И register-чеку, а тот использует `isTargetWordLetter` (эффект тот же для кириллицы). Фикс: атрибуция уточнена (тема пачки — коммент↔код). + +Пост-фикс перепрогон: build/vet/`-race ./...` зелёные · golden не двинулся (brief_hash `c2021b1b…`) · labels фрозен · майнер-парити EXACT · gofmt чист. Манифактуренной сходимости не найдено; инварианты подтверждены независимым запуском ревьюеров. + +**СТОП — приёмка оркестратора.**