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

This commit is contained in:
Claude (backend session) 2026-08-02 01:50:20 +03:00
parent 537657cf12
commit 9f60746c0c
34 changed files with 834 additions and 116 deletions

View file

@ -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/<book>/… — 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
}

View file

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

View file

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

View file

@ -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 ⟨проверить⟩.")
}

View file

@ -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). `pattern<TAB>key<TAB>value`; value VERBATIM. ---
# DC1: count before 时辰 (src); Russian «<count> час…» 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}%)

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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', ' ', '·', '・':

View file

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

View file

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

View file

@ -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 `%s<TAB>key<TAB>value`, 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_hour<TAB>word<TAB>value`, 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_neg<TAB>lexeme`, 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
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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/<stamp>.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
}

View file

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

View file

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

View file

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

View file

@ -1,3 +1,8 @@
<!-- DORMANT (D30.1): this is the MONOLINGUAL editor variant, preserved for the D13.1 pilot arm — it is NOT
wired into any live pipeline (the live editor is editor.md, bilingual). It is reached only via an explicit
prompt_override on an editor stage, and is pinned present by runner_memory_test.go. This editorial note is
stripped before the model ever sees it (backlog 14b) and moves no PromptSHA256 (the hash is over the
comment-free form; and no live pack loads this file). Row 53 disposition. -->
Ты — монолингвальный литературный редактор перевода на русский язык. Ты видишь ТОЛЬКО черновик перевода, без исходного текста.
Жанр книги: {{genre}}. Аудитория: {{audience}}. Книга: «{{title}}».

File diff suppressed because one or more lines are too long

View file

@ -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; остальные 0116 — отчёты закрытых экспериментов (судьба — в баннерах/D-логе).
- `research/` — фактура ресёрчей 0122; у принятых — ревью-шапки, часть тел под ⚠ 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).

View file

@ -1,4 +1,4 @@
# Журнал решений оркестратора — контракт D1D39.81 (развязки 04.07 · пакеты 0910.07 · приёмка/качество-первым/пивот/эмпирика 1112.07 · арх-ресет+стройка пере-прогонного стека 1319.07)
# Журнал решений оркестратора — контракт D1D39.82 (развязки 04.07 · пакеты 0910.07 · приёмка/качество-первым/пивот/эмпирика 1112.07 · арх-ресет+стройка пере-прогонного стека 1319.07)
> **КАРТА АКТУАЛЬНОСТИ (ревизия D31, продлена до D38.2 [12.07]; исторические записи ниже НЕ переписываются — дисциплина D23.3).** Читая контракт целиком, держи под рукой, что чем перекрыто:
> ⚠ **Навигация (актуализация 01.08):** карта ниже детально покрывает D1D39.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). ✅

View file

@ -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. Пункты (каждому — вердикт-тройка в отчёте)

View file

@ -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-арм, не в живом пайплайне; пометки в файле не было). (в) **Сделано:** `<!-- DORMANT (D30.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 <category> 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/<stamp>.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-поле + тест) = новый класс чекера ≈5060 строк 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 чист. Манифактуренной сходимости не найдено; инварианты подтверждены независимым запуском ревьюеров.
**СТОП — приёмка оркестратора.**