diff --git a/backend/internal/config/prompt_lint_test.go b/backend/internal/config/prompt_lint_test.go new file mode 100644 index 00000000..b82d3e8f --- /dev/null +++ b/backend/internal/config/prompt_lint_test.go @@ -0,0 +1,89 @@ +package config + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +// prompt_lint_test.go: a data lint over the SHIPPED prompt packs of every pair. It exists because of a +// cold-run measurement, and it lints the class rather than the instance. +// +// What happened: `translator-banknote.md` spelled the wire format as +// `исходный_терминпредлагаемый_переводтип`, and the model wrote the six characters `` +// instead of a tab. Every line of that block then had one field instead of three, so the whole densest +// block of the run was rejected — 20 of 135 lines, including a term the owner had signed by hand. +// +// The root cause is not the parser and not that model: it is a prompt that shows a control character as a +// word a generator can copy. The fix that generalises is therefore here, over the DATA, and over every +// pair — a ja-ru or en-ru pack authored by copying the zh-ru one would otherwise inherit the same defect +// silently, and the engine cannot see it (a prompt is opaque bytes to it). +func TestPromptPacksNameNoControlCharacterPlaceholders(t *testing.T) { + // Tokens that name a control character instead of being one. A prompt template uses {{...}} for + // substitution, so an angle-bracketed token is never legitimate content here. + banned := []string{"", "", "", "", "", "", "<таб>", "<табуляция>"} + + root := filepath.Join("..", "..", "prompts") + seen := 0 + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err != nil { + return err + } + if info.IsDir() || !strings.HasSuffix(path, ".md") { + return nil + } + b, rerr := os.ReadFile(path) + if rerr != nil { + return rerr + } + seen++ + low := strings.ToLower(string(b)) + for _, tok := range banned { + if strings.Contains(low, tok) { + t.Errorf("%s writes %q as a literal token: a model copies it verbatim and the field split then sees one field instead of three (measured: a whole banknote block lost). Put the real character in the example line instead.", path, tok) + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if seen == 0 { + t.Fatal("the lint walked no prompt files — a moved prompts root would make this test silently vacuous") + } + t.Logf("linted %d prompt files across every pair pack", seen) +} + +// TestBanknotePromptShowsARealTab is the positive half: the banknote prompt of every pair that ships one +// must demonstrate the delimiter by USING it. Checking only the absence of the bad token would pass on a +// prompt that describes the format in words and shows no example at all — which is how the ambiguity +// came back the first time. +func TestBanknotePromptShowsARealTab(t *testing.T) { + root := filepath.Join("..", "..", "prompts") + pairs, err := os.ReadDir(root) + if err != nil { + t.Fatal(err) + } + checked := 0 + for _, p := range pairs { + if !p.IsDir() { + continue + } + path := filepath.Join(root, p.Name(), "translator-banknote.md") + b, err := os.ReadFile(path) + if os.IsNotExist(err) { + continue // a pair without the channel ships no such prompt — legitimate + } + if err != nil { + t.Fatal(err) + } + checked++ + if !strings.Contains(string(b), "\t") { + t.Errorf("%s asks for a tab-delimited table but contains no tab character — the format has to be SHOWN, not named", path) + } + } + if checked == 0 { + t.Skip("no pair ships a banknote prompt") + } +} diff --git a/backend/internal/membank/memory.go b/backend/internal/membank/memory.go index 39466d31..95c83987 100644 --- a/backend/internal/membank/memory.go +++ b/backend/internal/membank/memory.go @@ -769,16 +769,25 @@ func RenderEditorConstraintBlock(injected []PickedEntry, tx lang.InjectionTexts) return b.String() } -// editorLines renders one section of the editor block: the CONFIRMED canon (confirmed=true) or the -// unverified working versions (confirmed=false). Deduped by (src,dst) within the section, in the budget -// priority order Select fixed. The gender directive is CONFIRMED-only — an unsigned row's gender is as -// unverified as its rendering, and directing the editor with it would smuggle an unverified datum in as -// an instruction. -func editorLines(injected []PickedEntry, tx lang.InjectionTexts, confirmed bool) []string { +// editorLines renders one section of the editor block: the SIGNED canon (signed=true) or the unverified +// working versions (signed=false). Deduped by (src,dst) within the section, in the budget priority order +// Select fixed. The gender directive stays CONFIRMED-only — an unsigned row's gender is as unverified as +// its rendering, and directing the editor with it would smuggle an unverified datum in as an instruction. +// +// The section follows PROVENANCE (status), not the injection disposition, and the two answer different +// questions (cold-run session, backlog 19 — the sibling of the banknote src rule on the same axis). +// dispositionFor downgrades an APPROVED row to AMBIGUOUS when its firing key is collision-prone — short +// and purely phonetic (memory.go:644) — which says "this key may have fired inside an unrelated word", +// a statement about the MATCH. Routing on it put an owner-SIGNED rendering under the header that says +// nobody has confirmed this, a statement about the AUTHORITY. On zh the branch cannot fire (Han keys +// anchor at 2), so the misrouting was invisible; on a phonetic source it would hit exactly the short +// signed names. Ambiguous still does its own job: the forced post-check and the ⟨проверить⟩ marker in the +// TRANSLATOR block are unchanged. +func editorLines(injected []PickedEntry, tx lang.InjectionTexts, signed bool) []string { var lines []string seen := map[[2]string]bool{} for _, p := range injected { - if !p.valid() || (p.Disp == Confirmed) != confirmed { + if !p.valid() || (p.entry.status == "approved") != signed { continue } src := strings.TrimSpace(p.entry.src) @@ -792,8 +801,13 @@ func editorLines(injected []PickedEntry, tx lang.InjectionTexts, confirmed bool) } seen[key] = true line := "- " + src + " → «" + dst + "»" - if confirmed { - line += genderConstraintNote(p.entry.gender, tx) + if signed { + // The gender directive still keys on the MATCH being confirmed: on a collision-prone firing + // the row may not be about the entity on the page, and a gender instruction is the one part of + // the line that would then be actively wrong rather than merely redundant. + if p.Disp == Confirmed { + line += genderConstraintNote(p.entry.gender, tx) + } } else { line += tx.UnverifiedMarker } diff --git a/backend/internal/membank/memseed_test.go b/backend/internal/membank/memseed_test.go index 35fdede4..8f8123bb 100644 --- a/backend/internal/membank/memseed_test.go +++ b/backend/internal/membank/memseed_test.go @@ -504,3 +504,24 @@ func TestUnverifiedKeyConflicts(t *testing.T) { t.Fatalf("disjoint windows are not a contradiction, got %v", got) } } + +// TestApprovedSharedKeyCollisionSeesAllowShort closes a mutation that SURVIVED the cold-run session's +// table: dropping `|| e.AllowShort` from entryFiringKeys broke no test, so the collision check's view of +// an allow_short key was unpinned. It matters precisely for the cold run's live signature — 蛊/转 reach +// the matcher ONLY through that override (D39.50 п.5), and a livelock the check cannot see is a livelock +// that ships. +func TestApprovedSharedKeyCollisionSeesAllowShort(t *testing.T) { + rows := []store.GlossaryEntry{ + {Src: "蛊", Dst: "гу", Status: "approved", Sense: "существо", AllowShort: true}, + {Src: "蛊", Dst: "Гу", Status: "approved", Sense: "род", AllowShort: true}, + } + if got := ApprovedSharedKeyCollisions(rows); len(got) != 1 { + t.Fatalf("two signed senses of the same allow_short key must be reported once, got %d: %v", len(got), got) + } + // Without the override the key cannot fire at all, so the same pair is inert and must NOT be reported + // (the check mirrors the matcher — over-reporting here fails a load that would have run fine). + rows[0].AllowShort, rows[1].AllowShort = false, false + if got := ApprovedSharedKeyCollisions(rows); len(got) != 0 { + t.Fatalf("a single-key-BANNED shared src is inert and must not fail the load, got: %v", got) + } +} diff --git a/backend/internal/membank/render_test.go b/backend/internal/membank/render_test.go index 64a09bd2..7b35b407 100644 --- a/backend/internal/membank/render_test.go +++ b/backend/internal/membank/render_test.go @@ -78,3 +78,46 @@ func TestZeroValuePickedEntryAndBankDoNotPanic(t *testing.T) { t.Fatalf("a zero-value bank must select nothing, got %+v", sel.Injected) } } + +// TestEditorSectionFollowsProvenance pins the S7 fix (cold-run session, backlog 19): the editor's two +// sections separate WHO SIGNED a rendering, not how confident the match was. dispositionFor downgrades an +// approved row matched by a collision-prone key (short + purely phonetic) to AMBIGUOUS — a statement about +// the firing, not the authority — and routing the section on it filed the owner's own signature under +// «никем не утверждено». Invisible on zh (Han keys anchor at 2), structural on any phonetic source. +func TestEditorSectionFollowsProvenance(t *testing.T) { + signedShort := PickedEntry{entry: &entry{src: "リン", dst: "Рин", status: "approved", gender: "female"}, Via: "りん", Disp: Ambiguous} + unsigned := PickedEntry{entry: &entry{src: "方源", dst: "Фан Юань", status: "draft"}, Via: "方源", Disp: Ambiguous} + + block := RenderEditorConstraintBlock([]PickedEntry{signedShort, unsigned}, ruTX()) + tx := ruTX() + canon, rest, ok := strings.Cut(block, tx.EditorUnverifiedHeader) + if !ok { + t.Fatalf("both sections must be present, got:\n%s", block) + } + if !strings.Contains(canon, "リン → «Рин»") { + t.Errorf("an owner-SIGNED row belongs to the canon section, got:\n%s", canon) + } + if strings.Contains(canon, "方源") { + t.Errorf("an unsigned row must not be canon, got:\n%s", canon) + } + if !strings.Contains(rest, "方源 → «Фан Юань»") || !strings.Contains(rest, tx.UnverifiedMarker) { + t.Errorf("the unsigned row must be in the marked section, got:\n%s", rest) + } + if strings.Contains(canon, tx.UnverifiedMarker) { + t.Errorf("the canon section must carry no unverified marker, got:\n%s", canon) + } + // The gender DIRECTIVE still keys on the match, not on the signature: on a collision-prone firing the + // row may not be about the entity on the page, and «женский род» would then be an instruction to get + // it wrong. Authority moves the section; confidence keeps the directive. + if strings.Contains(canon, tx.GenderFemale) { + t.Errorf("a collision-prone firing must carry no gender directive, got:\n%s", canon) + } + // The disposition itself is UNCHANGED — it still guards the post-check and the translator block's + // ⟨проверить⟩ tag. Only the section moved. + if signedShort.Disp != Ambiguous { + t.Fatal("the fix must not upgrade the disposition; it only re-routes the section") + } + if draft := RenderGlossaryBlock([]PickedEntry{signedShort}, ruTX()); !strings.Contains(draft, tx.UnverifiedMarker) { + t.Errorf("the TRANSLATOR block still marks a collision-prone match unverified, got: %q", draft) + } +} diff --git a/backend/internal/pipeline/allowshort_wire_test.go b/backend/internal/pipeline/allowshort_wire_test.go new file mode 100644 index 00000000..2a707842 --- /dev/null +++ b/backend/internal/pipeline/allowshort_wire_test.go @@ -0,0 +1,91 @@ +package pipeline + +import ( + "context" + "strings" + "testing" +) + +// allowshort_wire_test.go pins backlog 18 (D39.50 п.5) END TO END: an owner-SIGNED single-character term +// (蛊, 转 — the two the polygon found unreachable) must reach the MODEL when the seed says allow_short, +// and must stay unreachable without it. The bank-level override was already pinned (membank), but the +// finding was «не доезжают ни одним каналом», which is a statement about the whole path — seed file → +// materialization → selection → rendered injection → wire. The cold run's live signature relies on this +// path, so it is verified at $0 first. + +const shortSeedAllowed = ` +terms: + - src: 蛊 + dst: гу + type: term + status: approved + allow_short: true + decl: { invariant: true, forms: ["гу"] } +` + +const shortSeedPlain = ` +terms: + - src: 蛊 + dst: гу + type: term + status: approved + decl: { invariant: true, forms: ["гу"] } +` + +// shortSource names 蛊 twice — nothing else in the chunk can produce the dst, so the rendering's presence +// on the wire is proof the key fired. +const shortSource = "方源看着蛊,那是一只月光蛊。" + +func TestAllowShortReachesTheWire(t *testing.T) { + for _, c := range []struct { + name string + seed string + wantHit bool + }{ + {"allow_short signs the single-char key through", shortSeedAllowed, true}, + {"without it the single-key ban still holds", shortSeedPlain, false}, + } { + t.Run(c.name, func(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, func(body string) (string, string) { + if isEditBody(body) { + return "Фан Юань смотрел на гу.", "stop" + } + return "Фан Юань смотрел на гу — лунный гу.", "stop" + }) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: shortSource, glossarySeed: c.seed}) + + r := newRunner(t, bookPath) + defer r.Close() + if _, err := r.TranslateBook(context.Background()); err != nil { + t.Fatal(err) + } + msgs := bodyMessages(t, translatorBody(t, rec)) + injected := false + for _, m := range msgs { + if m.Role == "system" && strings.Contains(m.Content, "蛊 → гу") { + injected = true + } + } + if injected != c.wantHit { + t.Fatalf("single-char key on the wire = %v, want %v; messages: %+v", injected, c.wantHit, msgs) + } + // The editor half of the same claim: a signed row is CANON for the bilingual editor, and the + // section it lands in is the signed one (backlog 19 — provenance, not match confidence). + for _, b := range rec.all() { + if !isEditBody(b) { + continue + } + em := bodyMessages(t, b) + got := strings.Contains(em[1].Content, "蛊 → «гу»") + if got != c.wantHit { + t.Fatalf("editor canon carries the short term = %v, want %v: %q", got, c.wantHit, em[1].Content) + } + if c.wantHit && strings.Contains(em[1].Content, "⟨проверить⟩") { + t.Fatalf("an owner-SIGNED row must not be rendered as unverified: %q", em[1].Content) + } + } + }) + } +} diff --git a/backend/internal/pipeline/banknote.go b/backend/internal/pipeline/banknote.go index b9765913..32abda71 100644 --- a/backend/internal/pipeline/banknote.go +++ b/backend/internal/pipeline/banknote.go @@ -8,6 +8,7 @@ import ( "regexp" "sort" "strings" + "sync" "unicode" "textmachine/backend/internal/chunk" @@ -23,8 +24,8 @@ import ( // glossary). This file is the pure, deterministic PARSER + SLICER — the wire-invariant core; the // integration seams (slice BEFORE classifyOutput, derived checkpoint, snapshot fold, telemetry — // plan §4(а) 12 points) consume these functions. A faithful Go↔Python port (banknote.py is the -// reference): same separator, same tolerant field split, same Han-in-src rule, same truncation -// tolerance. Pin (ws4_banknote_verify.py): re-parse of 95 saved lines = 0 parse_fail / 0 truncated. +// reference): same separator, same tolerant field split, same truncation tolerance. The src rule +// DIVERGES deliberately (v2, backlog 19): attested-in-source instead of the reference's Han range. Pin (ws4_banknote_verify.py): re-parse of 95 saved lines = 0 parse_fail / 0 truncated. // bankSeparator marks the start of the banknote block. Chosen to (a) not occur in natural prose and // (b) NOT be a "Примечание/Сноска/Комментарий" trailing-note word the sanitizer reserves (§B3-1), so @@ -42,10 +43,26 @@ const bankSeparator = "⟦TM-BANK-v1⟧" // reservation is now derived rather than guessed — see bankTokenBudget. const bankMaxLines = 20 -// bankParserVersion versions the split/parse algorithm — the verdict-axis component folded into -// banknoteSnap{enabled, parser_version} (§4в/§4а point 6), mirroring sanitizerSnap: a parser change -// re-resolves the stripped draft, so it must be a loud --resnapshot even without a prompt edit. -const bankParserVersion = "banknote-v1" +// The channel has TWO versioned algorithms, and only one of them can change a paid artifact. Splitting +// them (cold-run session) is what makes the fold honest in both directions: +// +// - bankSliceVersion governs splitBanknote — where the block is cut off the raw answer. Its output IS +// the stripped draft: a checkpoint, the editor's input, the export. FOLDED into banknoteSnap; a change +// re-resolves paid text, so it must be a loud --resnapshot. +// - bankParseVersion governs parseBanknote — which candidate lines are accepted. Its output is EVIDENCE +// for the owner's signature, never a checkpoint and never a wire byte; every run recomputes it from +// the stored answers (bankObservedForBook). NOT folded, logged with the run — the same contract +// gates.terminology and gates.voice already carry, and for the same reason. +// +// Folding the parse rule (as a single parser_version did) would re-bill a whole draft wave for a change +// that cannot alter one byte of what was bought — which is a standing incentive to leave the rule wrong. +const ( + bankSliceVersion = "banknote-slice-v1" + // v2 (backlog 19 / D39.53 default): the src rule stopped being "contains a Han ideograph" (a language + // FAMILY hardcoded in the engine) and became "occurs in the book"; v2.1 adds the column-order recovery + // that same invariant makes possible. + bankParseVersion = "banknote-parse-v2.1-src-attested+column-order" +) // bankDerivedNS is the derived-checkpoint id NAMESPACE (§4б EXACT formula) — the "tm--v1" prefix // convention the sibling commitSanitizedExport uses ("tm-sanitized-v1"), kept DISTINCT from the parser @@ -74,7 +91,7 @@ type bankEntry struct { // bankFlags is the per-chunk telemetry surfaced loud at integration point 10. type bankFlags struct { NLines int // accepted (well-formed) banknote lines - ParseFail bool // any malformed residual line (non-Han src / <2 fields) that was NOT a tolerated truncation + ParseFail bool // any malformed residual line (src not in the book / <2 fields) that was NOT a tolerated truncation Truncated bool // the LAST line was cut by generation length (tolerated, not a parse fail) } @@ -120,23 +137,73 @@ func malformedSeparatorAt(output string) int { return 0 } -// hasBankSrcHan reports whether src contains a CJK ideograph in the exact [㐀-鿿] range (U+3400–U+9FFF) -// banknote.py checks — a zh→ru channel line whose src has no Han is malformed. Kept as the exact -// Python range (NOT unicode.Han, which is wider) for byte-faithful parity. -func hasBankSrcHan(src string) bool { - for _, r := range src { - if r >= 0x3400 && r <= 0x9FFF { +// bankSourceIndex answers the parser's src question — "is this surface actually in the book?" — for one +// run. It replaces the Han-range test the Python reference used (D39.53 default, backlog 19): that rule +// was a language FAMILY hardcoded in the engine, so on any non-ideographic source EVERY line was rejected +// with parse_fail and the channel that carries 5/6 of the bank's candidates was dead. "Occurs in the +// source" is pair-blind by construction and strictly stronger where the old rule worked at all: it also +// rejects a surface the model invented, which the Han test accepted as long as it looked Chinese. +// +// The book side is the CONCATENATION of the chunk sources under the same normalization the miner uses +// (NormalizeSourceKey), which is what makes the per-chunk fast path a pure optimisation rather than a +// second rule: a chunk's normalized text is a substring of the joined text, so the two can never disagree. +// The "\n" join is the boundary guard — a term cannot straddle two chunks and count as attested. +type bankSourceIndex struct { + book string + mu sync.Mutex + memo map[string]bool +} + +// newBankSourceIndex builds the index once per run, in the composite root, so no call path can forget it. +func newBankSourceIndex(chunks []chunk.Chunk) *bankSourceIndex { + parts := make([]string, 0, len(chunks)) + for _, ch := range chunks { + parts = append(parts, text.NormalizeSourceKey(ch.Text)) + } + return &bankSourceIndex{book: strings.Join(parts, "\n"), memo: map[string]bool{}} +} + +// attested reports whether a NORMALIZED key occurs in the book. Memoized because the wave workers call it +// per banknote line and the book side is megabytes on a real book; the memo is a cache of a pure function, +// so it changes no verdict — only the cost of repeating one. +func (x *bankSourceIndex) attested(key string) bool { + x.mu.Lock() + defer x.mu.Unlock() + if v, ok := x.memo[key]; ok { + return v + } + v := strings.Contains(x.book, key) + x.memo[key] = v + return v +} + +// bankSrcAttested builds the parser's src predicate for one parse. The chunk that produced the block is +// the fast path (a banknote declares the terms of ITS fragment, so nearly every legitimate line hits it); +// the book-wide index is the actual rule and the fallback for a term attested elsewhere — and for the +// re-fold path, which reads stored answers and passes no chunk text. +func (r *Runner) bankSrcAttested(chunkSource string) func(string) bool { + nchunk := text.NormalizeSourceKey(chunkSource) + return func(src string) bool { + key := text.NormalizeSourceKey(strings.TrimSpace(src)) + if key == "" { + return false + } + if nchunk != "" && strings.Contains(nchunk, key) { return true } + if r.bankSrc == nil { + return false + } + return r.bankSrc.attested(key) } - return false } // parseBanknote parses the tab-delimited block (integration seam feeding §C evidence + telemetry). // Tolerant of a TRUNCATED final line when truncatedGeneration is set (§B3-5): a short last line under // truncation is flagged banknote_truncated, not counted as a parse fail. Any other malformed line -// (fewer than 2 fields, or a src with no Han) sets banknote_parse_fail. Deterministic, no time/rand. -func parseBanknote(block string, truncatedGeneration bool) ([]bankEntry, bankFlags) { +// (fewer than 2 fields, or a src NOT attested in the source) sets banknote_parse_fail. Deterministic, no +// time/rand: srcAttested is a pure function of the book text (bankSourceIndex). +func parseBanknote(block string, truncatedGeneration bool, srcAttested func(string) bool) ([]bankEntry, bankFlags) { var entries []bankEntry var flags bankFlags if strings.TrimSpace(block) == "" { @@ -174,7 +241,23 @@ func parseBanknote(block string, truncatedGeneration bool) ([]bankEntry, bankFla if !bankTypeOK[typ] { typ = "term" } - if !hasBankSrcHan(src) { + // The src rule: a bank line must name something the BOOK contains. A surface no chunk of the source + // carries is either a hallucination or a mis-split line — not evidence about this book, and letting + // it through would put an invented term on the owner's sign map. + // + // The same invariant IDENTIFIES the source column, so a model that emits the pair in the other order + // is recovered instead of discarded (measured: one chunk emitted all 12 of its lines reversed, + // including a term the owner had signed by hand). Order is decided, never guessed: the declared + // order wins whenever it is attested, the reversal is taken only when it is the ONLY reading the + // book supports, and a line neither reading supports is still a parse fail. + switch { + case srcAttested == nil: + bad++ + continue + case srcAttested(src): // declared order — including the ambiguous case where both sides are attested + case srcAttested(dst): + src, dst = dst, src + default: bad++ continue } @@ -217,8 +300,8 @@ var bankWorstLineTokens = EstimateTokens(strings.Repeat("蛊", 8) + "\t" + strin // (point 10). Candidates are PARSED/accepted only under finish=="stop" (the finish=stop-only gate §4б — // a truncated block is not trusted); the slice itself runs regardless, so the length/echo classify never // sees the footnote's Han src column. Deterministic (a pure function of the raw text + gate state). -func (r *Runner) applyBanknote(role, rawText, finish string) (clean, stripped string, flags bankFlags) { - clean, stripped, flags, _ = r.applyBanknoteWithEntries(role, rawText, finish) +func (r *Runner) applyBanknote(role, rawText, finish, chunkSource string) (clean, stripped string, flags bankFlags) { + clean, stripped, flags, _ = r.applyBanknoteWithEntries(role, rawText, finish, chunkSource) return clean, stripped, flags } @@ -233,7 +316,7 @@ func (r *Runner) applyBanknote(role, rawText, finish string) (clean, stripped st // They stay OUT of the bank. A proposal is evidence for the SIGNATURE, never a canon: it reaches the // owner's sign map as `status: auto` and becomes a term only when the owner signs it. That boundary is // what keeps "the model suggested it" from silently becoming "the book uses it". -func (r *Runner) applyBanknoteWithEntries(role, rawText, finish string) (clean, stripped string, flags bankFlags, entries []bankEntry) { +func (r *Runner) applyBanknoteWithEntries(role, rawText, finish, chunkSource string) (clean, stripped string, flags bankFlags, entries []bankEntry) { if role != roleTranslator { return rawText, "", bankFlags{}, nil // the editor never emits banknotes } @@ -260,7 +343,7 @@ func (r *Runner) applyBanknoteWithEntries(role, rawText, finish string) (clean, if finish == "stop" { // finish=stop-only gate: accept candidates + count telemetry only for a complete generation. // truncatedGeneration=false is unreachable-otherwise in prod (a truncated gen is finish≠stop). - entries, flags = parseBanknote(block, false) + entries, flags = parseBanknote(block, false, r.bankSrcAttested(chunkSource)) } return cleanText, cleanText, flags, entries } @@ -417,8 +500,10 @@ func (r *Runner) bankObservedForBook() ([]terminology.Observed, int, error) { } for _, a := range answers { // The same slice/parse the live path runs, so the trust rule (a complete generation only) and the - // derived $0 rows are handled by one definition rather than by a second copy here. - _, _, _, entries := r.applyBanknoteWithEntries(roleTranslator, a.ResponseText, a.FinishReason) + // derived $0 rows are handled by one definition rather than by a second copy here. No chunk text is + // passed: this path has only the stored answer, so the src rule resolves against the whole book — + // the same verdict the live path reaches, since the chunk check is a subset of it. + _, _, _, entries := r.applyBanknoteWithEntries(roleTranslator, a.ResponseText, a.FinishReason, "") f.add(a.Chapter, a.ChunkIdx, bankProposalsOf(entries)) } return f.observed(), f.OffLanguage, nil diff --git a/backend/internal/pipeline/banknote_integration_test.go b/backend/internal/pipeline/banknote_integration_test.go index 8faae5f9..c0218f8c 100644 --- a/backend/internal/pipeline/banknote_integration_test.go +++ b/backend/internal/pipeline/banknote_integration_test.go @@ -21,13 +21,20 @@ import ( // bankBlock is a well-formed 2-line banknote block a translator emits after a clean RU translation. const bankBlock = bankSeparator + "\n龙公\tЛун Гун\tname\n蛊\tгу\tterm" +// bankSource is the fixture's SOURCE text. It must contain the two surfaces bankBlock declares: since +// backlog 19 the parser accepts a line only when the book actually attests its src (banknote.go), so a +// fixture whose source never mentioned them would exercise the reject path, not the channel. +const bankSource = "龙公的蛊。静かな図書館の朝。" + func TestBanknoteSliceReTelemetryResume(t *testing.T) { rec := &reqRec{} const cleanDraft = "Тихое утро в библиотеке." srv := newJSONProvider(rec, func(body string) (string, string) { if isEditBody(body) { // The editor MUST receive the CLEANED draft — the block was sliced off upstream (point 8). - if strings.Contains(body, bankSeparator) || strings.Contains(body, "龙公") { + // The tell-tale is the TABLE (separator + its proposed rendering), not the source surface: the + // fixture's source legitimately contains 龙公 and the editor is bilingual, so it sees it. + if strings.Contains(body, bankSeparator) || strings.Contains(body, "Лун Гун") { t.Errorf("editor request leaked the banknote block:\n%s", body) } return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД", "stop" @@ -35,7 +42,7 @@ func TestBanknoteSliceReTelemetryResume(t *testing.T) { return cleanDraft + "\n" + bankBlock, "stop" }) defer srv.Close() - bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true}) + bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true, source: bankSource}) ctx := context.Background() r1 := newRunner(t, bookPath) @@ -109,7 +116,7 @@ func TestBanknoteClassifyOverCleaned(t *testing.T) { return "Он ушёл." + "\n" + bigBlock, "stop" }) defer srv.Close() - bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true}) + bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true, source: bankSource}) r := newRunner(t, bookPath) defer r.Close() @@ -135,7 +142,7 @@ func TestBanknoteSnapshotFold(t *testing.T) { defer srv.Close() off := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{})) defer off.Close() - on := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{banknote: true})) + on := newRunner(t, setupProjectOpts(t, srv.URL, projectOpts{banknote: true, source: bankSource})) defer on.Close() offID, offPayload, err := off.snapshotID() @@ -152,8 +159,14 @@ func TestBanknoteSnapshotFold(t *testing.T) { if strings.Contains(offPayload, "banknote") { t.Fatalf("a banknote-OFF snapshot must omit the field (omitempty), got: %s", offPayload) } - if !strings.Contains(onPayload, fmt.Sprintf(`"banknote":{"enabled":true,"parser_version":"%s","token_budget":%d}`, bankParserVersion, bankTokenBudget)) { - t.Fatalf("a banknote-ON snapshot must fold {enabled,parser_version,token_budget}, got: %s", onPayload) + if !strings.Contains(onPayload, fmt.Sprintf(`"banknote":{"enabled":true,"slice_version":"%s","token_budget":%d}`, bankSliceVersion, bankTokenBudget)) { + t.Fatalf("a banknote-ON snapshot must fold {enabled,slice_version,token_budget}, got: %s", onPayload) + } + // The PARSE rule must NOT be in there: it decides which candidate lines are accepted — evidence for the + // owner's signature, recomputed from stored answers on every run — and folding it would re-bill a paid + // draft wave for a change that cannot alter one byte of what was bought. + if strings.Contains(onPayload, bankParseVersion) || strings.Contains(onPayload, "parse_version") { + t.Fatalf("the parse rule must ride with the run, not with the snapshot, got: %s", onPayload) } } @@ -171,7 +184,7 @@ func TestBanknoteFinishStopOnlyGate(t *testing.T) { return "Незаконченный перевод" + "\n" + bankSeparator + "\n龙公\tЛун", "length" }) defer srv.Close() - bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true, regenerate: 0}) + bookPath := setupProjectOpts(t, srv.URL, projectOpts{banknote: true, source: bankSource, regenerate: 0}) r := newRunner(t, bookPath) defer r.Close() diff --git a/backend/internal/pipeline/banknote_test.go b/backend/internal/pipeline/banknote_test.go index 26a23f88..900a171e 100644 --- a/backend/internal/pipeline/banknote_test.go +++ b/backend/internal/pipeline/banknote_test.go @@ -4,6 +4,7 @@ import ( "strings" "testing" + "textmachine/backend/internal/chunk" "textmachine/backend/internal/config" ) @@ -11,6 +12,17 @@ import ( // The 95-line corpus pin (0 parse_fail / 0 truncated over 14 saved raw outputs) is the domain of the // $0 reference eval/design11/ws4_banknote_verify.py; these unit tests cover the parser CONTRACT. +// attestedIn is the test double for the src rule: the surfaces the "book" contains. The production +// predicate is bankSrcAttested over bankSourceIndex; both answer the same question over the same +// normalization, and TestBankSourceIndexParity pins that they agree. +func attestedIn(source string) func(string) bool { + r := &Runner{bankSrc: newBankSourceIndex([]chunk.Chunk{{Text: source}})} + return r.bankSrcAttested("") +} + +// srcAll is the corpus every legacy parser test above was written against. +const srcAll = "方源\n蛊师\n青茅山\n古月\n蛊\n龙公\nRoseanne\n" + func TestSplitBanknoteNoSeparatorIsAllClean(t *testing.T) { out := "Судзуки шёл по коридору. \n" clean, block, _ := splitBanknote(out) @@ -28,7 +40,7 @@ func TestSplitBanknoteSlicesBlock(t *testing.T) { if strings.Contains(clean, bankSeparator) || strings.Contains(clean, "方源") { t.Fatalf("the banknote block leaked into the clean translation: %q", clean) } - ents, flags := parseBanknote(block, false) + ents, flags := parseBanknote(block, false, attestedIn(srcAll)) if len(ents) != 2 || flags.ParseFail || flags.Truncated || flags.NLines != 2 { t.Fatalf("parse = %+v flags=%+v, want 2 clean entries", ents, flags) } @@ -44,7 +56,7 @@ func TestParseBanknoteTolerantFieldSplit(t *testing.T) { // tab, ≥2 spaces, and pipe are all accepted delimiters (a model that emits spaces instead of a // real TAB still parses). type falls back to "term" when absent or unknown. block := "方源\tФан Юань\tname\n蛊师 гу-мастер title\n青茅山 | гора Цинмао | place\n古月\tГу Юэ" - ents, flags := parseBanknote(block, false) + ents, flags := parseBanknote(block, false, attestedIn(srcAll)) if flags.ParseFail { t.Fatalf("tolerant split should not fail: %+v", flags) } @@ -56,15 +68,92 @@ func TestParseBanknoteTolerantFieldSplit(t *testing.T) { } } -func TestParseBanknoteNonHanSrcIsBad(t *testing.T) { - // A zh→ru channel line whose src has no Han ideograph is malformed → parse_fail. - block := "方源\tФан Юань\tname\nRoseanne\tРозанна\tname" - ents, flags := parseBanknote(block, false) - if !flags.ParseFail { - t.Fatalf("a non-Han src must set parse_fail") +// TestParseBanknoteSrcMustBeAttested replaces the old "src must contain a Han ideograph" pin (backlog 19, +// D39.53 default). Two things changed in ONE rule: a non-ideographic src is no longer malformed by +// construction (the channel was DEAD on every non-CJK source — 5/6 of the bank's candidates), and a src +// the book does not contain is now malformed even when it looks Chinese (the old rule accepted invented +// surfaces, which then reached the owner's sign map). +func TestParseBanknoteSrcMustBeAttested(t *testing.T) { + src := "方源 шёл по тропе. Roseanne ждала у Silversaint, рядом стоял San Michon.\nリン сказала: идём.\n" + block := strings.Join([]string{ + "方源\tФан Юань\tname", + "Roseanne\tРозанна\tname", // latin src, attested → ACCEPTED (was: parse_fail) + "Silversaint\tСеребряный святой\ttitle", + "San Michon\tСан-Мишон\tplace", // multi-word latin src + "リン\tРин\tname", // kana src + }, "\n") + ents, flags := parseBanknote(block, false, attestedIn(src)) + if flags.ParseFail { + t.Fatalf("every src here is in the source; none may be a parse fail: %+v", flags) } - if len(ents) != 1 || ents[0].Src != "方源" { - t.Fatalf("only the Han-src line should survive, got %+v", ents) + if len(ents) != 5 { + t.Fatalf("want all 5 attested lines, got %d: %+v", len(ents), ents) + } + // The other half of the rule: a plausible, well-formed, Han-bearing line the book never contains. + invented := "天魔宗\tСекта Небесного Демона\tplace" + ents, flags = parseBanknote(invented, false, attestedIn(src)) + if !flags.ParseFail || len(ents) != 0 { + t.Fatalf("an invented src must be a parse fail and yield nothing, got %+v %+v", ents, flags) + } + // And the empty src can never be attested. + if attestedIn(src)("") || attestedIn(src)(" ") { + t.Fatal("an empty src must never count as attested") + } +} + +// TestParseBanknoteRecoversColumnOrder pins the cold-run finding that the attested rule also SOLVES: a +// model that writes the pair in the other order (target first) used to lose the whole block. The order is +// decided by which side the book attests, so the recovery is pair-blind and never guesses. +func TestParseBanknoteRecoversColumnOrder(t *testing.T) { + src := "学堂家老在花海。San Michon стоял рядом." + block := strings.Join([]string{ + "старейшина школы\t学堂家老\ttitle", // reversed by the model + "花海\tМоре цветов\tplace", // declared order + "выдумка\tтоже выдумка\tterm", // neither side attested → still a parse fail + }, "\n") + ents, flags := parseBanknote(block, false, attestedIn(src)) + if !flags.ParseFail { + t.Fatalf("the unattested line must still fail: %+v", flags) + } + if len(ents) != 2 { + t.Fatalf("want the reversed line recovered and the normal one kept, got %+v", ents) + } + if ents[0].Src != "学堂家老" || ents[0].Dst != "старейшина школы" { + t.Fatalf("reversed line not normalized to (source, target): %+v", ents[0]) + } + if ents[1].Src != "花海" || ents[1].Dst != "Море цветов" { + t.Fatalf("declared order must be preserved: %+v", ents[1]) + } + // Ambiguity is resolved by the DECLARED order, never by a guess: when the book attests both fields + // (a source that quotes the target language, or a same-script pair), the model's order stands. + both := "San Michon\tСан-Мишон\tplace" + ents, flags = parseBanknote(both, false, attestedIn(src+" Сан-Мишон")) + if flags.ParseFail || len(ents) != 1 || ents[0].Src != "San Michon" { + t.Fatalf("both-attested must keep the declared order, got %+v %+v", ents, flags) + } +} + +// TestBankSourceIndexParity pins the property that lets the live path check the CHUNK first: a chunk hit +// implies a book hit, so the fast path can never accept a line the book-wide rule would reject (and the +// re-fold path, which passes no chunk, reaches the same verdict). +func TestBankSourceIndexParity(t *testing.T) { + chunks := []chunk.Chunk{{Chapter: 1, ChunkIdx: 0, Text: "方源 вышел из дома."}, {Chapter: 1, ChunkIdx: 1, Text: "古月赤城 ждал во дворе."}} + r := &Runner{bankSrc: newBankSourceIndex(chunks)} + for _, c := range chunks { + chunkPred, bookPred := r.bankSrcAttested(c.Text), r.bankSrcAttested("") + for _, probe := range []string{"方源", "古月赤城", "天魔宗", "вышел", ""} { + if chunkPred(probe) && !bookPred(probe) { + t.Fatalf("chunk %d accepted %q that the book-wide rule rejects — the fast path is not a subset", c.ChunkIdx, probe) + } + } + } + // A term attested in ANOTHER chunk is accepted through the book fallback, not silently dropped. + if !r.bankSrcAttested(chunks[0].Text)("古月赤城") { + t.Fatal("a surface attested elsewhere in the book must pass the fallback") + } + // The boundary guard: the join must not let a key straddle two chunks. + if r.bankSrcAttested("")("дома.古月赤城") { + t.Fatal("a key spanning a chunk boundary must not count as attested") } } @@ -72,18 +161,18 @@ func TestParseBanknoteTruncationTolerated(t *testing.T) { // The LAST line cut by generation length is tolerated (banknote_truncated), not a parse fail — // but ONLY under truncated_generation; the same short line otherwise IS a parse fail. block := "方源\tФан Юань\tname\n蛊" // last line has no dst - entsT, flagsT := parseBanknote(block, true) + entsT, flagsT := parseBanknote(block, true, attestedIn(srcAll)) if !flagsT.Truncated || flagsT.ParseFail || len(entsT) != 1 { t.Fatalf("truncated=true: want 1 entry + truncated flag + no parse_fail, got %+v %+v", entsT, flagsT) } - entsF, flagsF := parseBanknote(block, false) + entsF, flagsF := parseBanknote(block, false, attestedIn(srcAll)) if flagsF.Truncated || !flagsF.ParseFail || len(entsF) != 1 { t.Fatalf("truncated=false: the short last line must be a parse_fail, got %+v %+v", entsF, flagsF) } } func TestParseBanknoteEmptyBlock(t *testing.T) { - ents, flags := parseBanknote("", false) + ents, flags := parseBanknote("", false, attestedIn(srcAll)) if len(ents) != 0 || flags.ParseFail || flags.Truncated || flags.NLines != 0 { t.Fatalf("empty block must yield no entries and clean flags, got %+v %+v", ents, flags) } @@ -112,9 +201,10 @@ func TestBankSeparatorIsNotANoteWord(t *testing.T) { func TestBanknoteSliceIsUnconditional(t *testing.T) { r := &Runner{Pipeline: &config.Pipeline{}} // gate OFF (zero value) const clean = "Тихое утро в библиотеке." + const bankTestSource = "龙公 сидел в библиотеке." raw := clean + "\n" + bankSeparator + "\n龙公\tЛун Гун\tname" - got, stripped, flags, entries := r.applyBanknoteWithEntries(roleTranslator, raw, "stop") + got, stripped, flags, entries := r.applyBanknoteWithEntries(roleTranslator, raw, "stop", bankTestSource) if got != clean || stripped != clean { t.Fatalf("the block must be sliced even with the gate off, got %q / %q", got, stripped) } @@ -122,11 +212,11 @@ func TestBanknoteSliceIsUnconditional(t *testing.T) { t.Fatalf("the WHAT must still be read (parsing is free), got %+v / %+v", entries, flags) } // The byte-identical path: no separator → the raw text back, no derived export. - if g, s, f, e := r.applyBanknoteWithEntries(roleTranslator, clean, "stop"); g != clean || s != "" || f != (bankFlags{}) || e != nil { + if g, s, f, e := r.applyBanknoteWithEntries(roleTranslator, clean, "stop", bankTestSource); g != clean || s != "" || f != (bankFlags{}) || e != nil { t.Fatalf("a draft with no separator must take the unchanged path, got %q/%q/%+v/%+v", g, s, f, e) } // The editor never emits banknotes — its output is never touched. - if g, s, _, _ := r.applyBanknoteWithEntries(roleEditor, raw, "stop"); g != raw || s != "" { + if g, s, _, _ := r.applyBanknoteWithEntries(roleEditor, raw, "stop", bankTestSource); g != raw || s != "" { t.Fatalf("the editor's output must not be sliced, got %q/%q", g, s) } } @@ -137,13 +227,14 @@ func TestBanknoteSliceIsUnconditional(t *testing.T) { func TestBanknoteMalformedSeparatorIsCaught(t *testing.T) { r := &Runner{Pipeline: &config.Pipeline{}} const clean = "Тихое утро в библиотеке." + const bankTestSource = "龙公 сидел в библиотеке." for _, broken := range []string{ "⟦TM-BANK-v2⟧", // a version the parser does not know "[TM-BANK-v1]", // the wrong brackets "⟦ TM-BANK-v1 ⟧", // stray spaces } { raw := clean + "\n" + broken + "\n龙公\tЛун Гун\tname" - got, stripped, flags, entries := r.applyBanknoteWithEntries(roleTranslator, raw, "stop") + got, stripped, flags, entries := r.applyBanknoteWithEntries(roleTranslator, raw, "stop", bankTestSource) if strings.Contains(got, "龙公") || strings.Contains(got, "TM-BANK") { t.Fatalf("%s: the malformed block must still be cut off the draft, got %q", broken, got) } @@ -158,7 +249,7 @@ func TestBanknoteMalformedSeparatorIsCaught(t *testing.T) { } } // A translation that merely mentions nothing of the sort is untouched — no false positives. - if got, _, flags, _ := r.applyBanknoteWithEntries(roleTranslator, clean, "stop"); got != clean || flags.ParseFail { + if got, _, flags, _ := r.applyBanknoteWithEntries(roleTranslator, clean, "stop", bankTestSource); got != clean || flags.ParseFail { t.Fatalf("ordinary prose must not trip the malformed-separator check: %q / %+v", got, flags) } } diff --git a/backend/internal/pipeline/bookrun.go b/backend/internal/pipeline/bookrun.go index 4165a1e1..eef98e37 100644 --- a/backend/internal/pipeline/bookrun.go +++ b/backend/internal/pipeline/bookrun.go @@ -141,6 +141,10 @@ func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) { if len(chunks) == 0 { return nil, fmt.Errorf("pipeline: source file %s produced no chunks after normalization", r.Book.SourceFile) } + // the precompute pass: the banknote parser's source index (backlog 19). Built here, in the composite + // root, because the rule "a bank line must name something the book contains" needs the whole chunk + // manifest, and building it anywhere else would let a call path exist without it. + r.bankSrc = newBankSourceIndex(chunks) // Consent to a RE-PAYMENT (D20.2-Q2, rebill.go) — BEFORE the waves, hence before the first Reserve: // if this run would pay again for units already billed under a superseded snapshot, and the amount diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go index bd5d4798..cdc05ea5 100644 --- a/backend/internal/pipeline/runner.go +++ b/backend/internal/pipeline/runner.go @@ -78,6 +78,11 @@ type Runner struct { // read-only in the waves — a transport axis, never snapshot-folded. nil until buildRateGuards. rateGuards map[string]*rateGuard + // bankSrc is the banknote parser's "is this surface in the book?" index (backlog 19), built once from + // the chunk manifest in the precompute pass. nil outside a book run → the parser then judges a line by + // the chunk text it was handed, and accepts nothing without one. + bankSrc *bankSourceIndex + // memory is the book's glossary FROZEN for this job (materialized once in // TranslateBook, after seeding, before snapshotID). Its Version() is the F1 // content-hash folded into the snapshot; its Select drives per-chunk injection. diff --git a/backend/internal/pipeline/snapshot.go b/backend/internal/pipeline/snapshot.go index 45dbd60d..b0ffc666 100644 --- a/backend/internal/pipeline/snapshot.go +++ b/backend/internal/pipeline/snapshot.go @@ -111,11 +111,14 @@ func (r *Runner) sanitizerSnapshot() sanitizerSnap { // a loud snapshot move (the substantive loudness point 6 requires). A snapshot re-pin is never billed for // a feature a book does not use. type banknoteSnap struct { - Enabled bool `json:"enabled"` - ParserVersion string `json:"parser_version,omitempty"` + Enabled bool `json:"enabled"` + // SliceVersion folds the SLICE algorithm only (banknote.go): it decides where the block is cut, and + // the cut text is a checkpoint. The PARSE rule rides with the run instead — it produces evidence, not + // paid bytes, and every run recomputes it from the stored answers. + SliceVersion string `json:"slice_version,omitempty"` // TokenBudget folds bankTokenBudget (FL-1): the extra max_tokens the enabled channel adds to the - // translator draft (stagerun.go, integration point 5) enters request_hash, but bankParserVersion - // versions only the split/parse ALGORITHM — so editing bankMaxLines (or the per-line multiplier) + // translator draft (stagerun.go, integration point 5) enters request_hash, but bankSliceVersion + // versions only the SLICE algorithm — so editing bankMaxLines (or the per-line multiplier) // would change the wire without bumping the parser version = a silent re-bill on resume. Folding // the resolved budget makes any such edit a loud --resnapshot by mechanism, not discipline. TokenBudget int `json:"token_budget,omitempty"` @@ -126,7 +129,7 @@ func (r *Runner) banknoteSnapshot() *banknoteSnap { if !r.Pipeline.Gates.Banknote.Enabled { return nil } - return &banknoteSnap{Enabled: true, ParserVersion: bankParserVersion, TokenBudget: bankTokenBudget} + return &banknoteSnap{Enabled: true, SliceVersion: bankSliceVersion, TokenBudget: bankTokenBudget} } // repairSnap freezes the addressable-defect repair sub-step (pack-16) inside the snapshot. It follows diff --git a/backend/internal/pipeline/stagerun.go b/backend/internal/pipeline/stagerun.go index 990f6273..acd2b607 100644 --- a/backend/internal/pipeline/stagerun.go +++ b/backend/internal/pipeline/stagerun.go @@ -412,7 +412,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID // Banknote: slice the block off the RAW checkpoint text BEFORE classify + before feeding the // editor (WS4 points 1-3,8). The checkpoint stores the RAW draft; resume re-derives the clean // text deterministically (a no-op returning the raw text for a channel-off / no-separator draft). - cleanText, bankStripped, bankFlags, bankEntries := r.applyBanknoteWithEntries(st.Role, cp.ResponseText, cp.FinishReason) + cleanText, bankStripped, bankFlags, bankEntries := r.applyBanknoteWithEntries(st.Role, cp.ResponseText, cp.FinishReason, ch.Text) att.text = cleanText att.bankStripped, att.bankFlags, att.bankProposals = bankStripped, bankFlags, bankProposalsJSON(bankEntries) att.cls, att.stripped = r.classifyOutput(st.Role, ch.Text, cleanText, cp.FinishReason, isFinal) @@ -603,7 +603,7 @@ func (r *Runner) runAttempt(ctx context.Context, st config.Stage, model, snapID // run-crash. The banknote block is sliced off the RAW draft FIRST (WS4 points 1-3,8): the // checkpoint above holds the raw response, but classify/coverage/echo and the editor see the // CLEANED translation, and the cleaned text is committed as a derived export in runStage. - cleanText, bankStripped, bankFlags, bankEntries := r.applyBanknoteWithEntries(st.Role, resp.Text, resp.FinishReason) + cleanText, bankStripped, bankFlags, bankEntries := r.applyBanknoteWithEntries(st.Role, resp.Text, resp.FinishReason, ch.Text) att.text = cleanText att.bankStripped, att.bankFlags, att.bankProposals = bankStripped, bankFlags, bankProposalsJSON(bankEntries) att.cls, att.stripped = r.classifyOutput(st.Role, ch.Text, cleanText, resp.FinishReason, isFinal) diff --git a/backend/prompts/zh-ru/terminologist.md b/backend/prompts/zh-ru/terminologist.md index 0f5ff530..bf94c55a 100644 --- a/backend/prompts/zh-ru/terminologist.md +++ b/backend/prompts/zh-ru/terminologist.md @@ -33,11 +33,10 @@ - Если по данным контекстам ты не можешь выбрать перевод уверенно — верни ⟦TM-NO-DST⟧. Это нормальный ответ: непереведённый термин останется на ручную подпись, а выдуманный попадёт в книгу. -Формат ответа — по одной строке на термин, ровно два поля через табуляцию: +Формат ответа — по одной строке на термин, ровно два поля, разделённые СИМВОЛОМ ТАБУЛЯЦИИ: +первое поле — термин исходника, второе — перевод. -термин_исходникаперевод - -Пример строки ответа: +Пример строки ответа (символ между полями — настоящая табуляция): 师父 наставник diff --git a/backend/prompts/zh-ru/translator-banknote.md b/backend/prompts/zh-ru/translator-banknote.md index 2a082013..255e9db4 100644 --- a/backend/prompts/zh-ru/translator-banknote.md +++ b/backend/prompts/zh-ru/translator-banknote.md @@ -17,7 +17,10 @@ БАНКНОТА (служебный канал). ПОСЛЕ полного текста перевода добавь строку-разделитель ⟦TM-BANK-v1⟧ и под ней — таблицу НОВЫХ имён собственных и терминов этого фрагмента, которых НЕТ в приложенном глоссарии. -Формат строки: исходный_терминпредлагаемый_переводтип +В каждой строке три поля — исходный термин, предлагаемый перевод, тип — РАЗДЕЛЁННЫЕ СИМВОЛОМ ТАБУЛЯЦИИ. +Пример строки (символ между полями — настоящая табуляция): +师父 наставник title +В ПЕРВОМ поле — фрагмент ИСХОДНОГО текста ровно в том виде, как он написан в оригинале (не перевод и не транскрипция); во втором — твой перевод. Тип — одно из: name, place, title, term, nickname. Не более 20 строк. Если новых терминов нет — не добавляй ни разделитель, ни таблицу. Разделитель и таблица идут ПОСЛЕ перевода и никогда внутри него. Разделитель пиши ТОЧНО как показано выше, diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 5188e488..c85b74bd 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,7 +1,7 @@ # Журнал прогресса > **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-07-26: пак-20 закрыт ЦЕЛИКОМ D39.41–53 — терминолог · флаговый стоп · двухсекционная инъекция · точечная ре-редактура · языковой экран; жанровый словарь отменён D39.47; полигон-пакеты 6/7/8 закрыты). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1–D39.53); этот файл — ЖУРНАЛ.** -> - **Фазы/курс:** Ф0 ✅ · Ф1-инфра ✅ (D20–D28; golden = инвариант №8) · арка «качество-первым» D26→D38.5 закрыта · **арх-ресет D39 исполнен ЦЕЛИКОМ** (7 слоёв → программа D39.1–D39.10 → паки 11–16, D39.13–D39.24; статус слоёв — шапка-таблица `architecture/09-target-architecture.md`) · пере-прогон rerun2 прочитан (D39.20: операционка живьём, $0-резюм ×3 арма, $6.63<$15; планка ≤2 НЕ пройдена; анти-корреляция гладкость↔верность ×3 → mistral-редактор вон) · эксп ЗАКРЫТ (D39.22), итерация №2 отложена · **курс = разработка бэкенда: пак-17 «канал B вживую» ЗАКРЫТ ЦЕЛИКОМ (дизайн D39.26 · стройка D39.27 · дельта `allow` D39.28, 25.07); вокабуляр = ПАРА `violence`/`sexually-explicit`, остаток — ФАКТЫ ToS в зоне полигона (fail-closed до них)** (D39.25: в движке НЕТ понятия «18+» — generic content-labels × provider-capabilities; лейблы вне хешей · резолв до валидации · один хоп `chain[0]` · гранулярность = книга) → **КУРС АМЕНДИРОВАН ВЛАДЕЛЬЦЕМ 25.07 (D39.33): МАСШТАБ — ПОСЛЕДНИМ, после достройки алгоритмического идеала; доказательство эффективности крупных изменений — МИНИ-ПРОГОНЫ ~10 глав (детерминированные чекеры и скан остатка первыми, судья — только когда от него зависит решение).** **ОЧЕРЕДЬ (D39.40/51/53, актуализация 31.07):** пак-19 «голос/состояние» ЗАКРЫТ ЦЕЛИКОМ (D39.54–56, вкл. малую пачку 14а+14б) → ХОЛОДНЫЙ мини-прогон (пустой сид, отдельная БД; + первое измерение осей голоса `gates.voice`) → пак-21 «чекеры» → свип гипотез → добор идеала (4 вопроса границы D39.33 у владельца) → МАСШТАБ целой книги (7,78 млн симв., ~2284 раздела) → пилот Ф2.5. Хроника треков A/B, exp15/16 и стройки паков — архивы ниже + D-лог. +> - **Фазы/курс:** Ф0 ✅ · Ф1-инфра ✅ (D20–D28; golden = инвариант №8) · арка «качество-первым» D26→D38.5 закрыта · **арх-ресет D39 исполнен ЦЕЛИКОМ** (7 слоёв → программа D39.1–D39.10 → паки 11–16, D39.13–D39.24; статус слоёв — шапка-таблица `architecture/09-target-architecture.md`) · пере-прогон rerun2 прочитан (D39.20: операционка живьём, $0-резюм ×3 арма, $6.63<$15; планка ≤2 НЕ пройдена; анти-корреляция гладкость↔верность ×3 → mistral-редактор вон) · эксп ЗАКРЫТ (D39.22), итерация №2 отложена · **курс = разработка бэкенда: пак-17 «канал B вживую» ЗАКРЫТ ЦЕЛИКОМ (дизайн D39.26 · стройка D39.27 · дельта `allow` D39.28, 25.07); вокабуляр = ПАРА `violence`/`sexually-explicit`, остаток — ФАКТЫ ToS в зоне полигона (fail-closed до них)** (D39.25: в движке НЕТ понятия «18+» — generic content-labels × provider-capabilities; лейблы вне хешей · резолв до валидации · один хоп `chain[0]` · гранулярность = книга) → **КУРС АМЕНДИРОВАН ВЛАДЕЛЬЦЕМ 25.07 (D39.33): МАСШТАБ — ПОСЛЕДНИМ, после достройки алгоритмического идеала; доказательство эффективности крупных изменений — МИНИ-ПРОГОНЫ ~10 глав (детерминированные чекеры и скан остатка первыми, судья — только когда от него зависит решение).** **ОЧЕРЕДЬ (D39.40/51/53, актуализация 31.07):** пак-19 «голос/состояние» ЗАКРЫТ ЦЕЛИКОМ (D39.54–56, вкл. малую пачку 14а+14б) → ХОЛОДНЫЙ мини-прогон ИСПОЛНЕН ДОСТАТОЧНО (D39.58, Р7 владельца: recall банка 0.918/0.980, банк приходит переведённым; хвосты 16/21/22/24/13б — со следующим живым прогоном или полигон-пакетом) → пак-21 «чекеры» (ждёт ответов Р1–Р4 разметки, строка 1) → свип гипотез → добор идеала (4 вопроса границы D39.33 у владельца) → МАСШТАБ целой книги (7,78 млн симв., ~2284 раздела) → пилот Ф2.5. Хроника треков A/B, exp15/16 и стройки паков — архивы ниже + D-лог. > - **Стек:** draft deepseek-v4-flash (thinking ON, +банкнота `translator-banknote.md`) → **терминолог** (роль пар-пака, deepseek-v4-flash, батчи, языковой экран `target_script` — D39.42/45/51/53) → **editor deepseek-v4-pro БИЛИНГВ ИНТЕРИМ (D39.22; glm-5 резерв)**, промпт v3-discourse, инъекция двухсекционная (D39.45) → судья gemini-3.1-pro-preview (Ф2, полигон); канал B Mistral+grok; 7 ключей; ~$0.85/ранобэ (D30.4). > - **ЕДИНЫЙ БЭКЛОГ — секция «Бэклог» ниже (введён 26.07 по решению владельца: одна таблица вместо наслоений; ревью-пасс оркестратора сверил её с коммитами и обсуждённым, добавил 7 строк). Норма прежняя: каждая петля обязана иметь диспозицию (решено / отложено-с-записью / отклонено); ведёт оркестратор. Историческая развёртка петель до 26.07 — в git-истории этого файла и в D-логе.** > - Архивы хроники: `archive/PROGRESS-2026-07-04-10.md` (D31) · `archive/PROGRESS-2026-07-10-13.md` (D39.6-гигиена) · `archive/PROGRESS-2026-07-13-25.md` (слайс 25.07: стройка паков 11–16, rerun2). Записи ниже — живой хвост. @@ -17,7 +17,7 @@ | 2 | Четыре вопроса границы «алгоритмического идеала» (Annotator · семантический судья · ja→ru §B5 · ru-target до/после) | владелец | скоро (гейт «добора идеала» перед МАСШТАБОМ) | отдельное решение | D39.33, D39.38 | | 3 | Одноразовый `--resnapshot` стендовых книг с auto-строками | владелец | скоро | отдельное решение | D39.45(4) | | 4 | Ре-чек ToS по триггерам — первый ИСПОЛНЕН 31.07, дельт вердиктов нет (D39.57); предмет Google-мониторинга перевешен на цепочку договора (PUP · Additional ToS · Google APIs ToS); следующие: квартал 25.10.2026 · первая лейблованная книга · любая правка `accepts_labels` | владелец/полигон | скоро (следующий триггер 25.10.2026) | отдельное решение (правило) | D39.32, D39.57 | -| 5 | Судья-с-декоем как катастроф-экран банкноты — отложен, вернуться после холодного старта и пере-замера P1 | владелец | скоро (после холодного мини-прогона) | отдельное решение | D39.50 п.6, D39.51 п.4, PACK20_BANKNOTE_BUILD §6 | +| 5 | Судья-с-декоем — условие «после холодного старта» НАСТУПИЛО (D39.58); форма возврата = рецензент второго мнения по банку, несогласия колонкой подписной таблицы (строка 36б) | владелец | скоро | дизайн 36б | D39.50 п.6, D39.51 п.4, D39.58 | | 6 | Строка Gemini в `accepts_labels` — ждёт слова владельца о подписи интерпретации сужения как разрешения; ре-чек 31.07 понизил приоритет: подпись даёт ПРАВО, но не судью 18+ (фильтр неконфигурируем D22.6 + обход запрещён договором) — возвращаться при вопросе, требующем именно права | владелец | когда-нибудь | отдельное решение | D39.32, D39.57 | | 7 | Q2 «редактор под `sexually-explicit`» (grok-4.3 интерим · glm-5 после сверки ToS Z.AI · mistral вон) | владелец | когда-нибудь (перед первой explicit-книгой) | отдельное решение | D39.27 п.7 | | 8 | D22.7 пер-чанковый L3-скрин — несня­тое предусловие первой erotica-книги в проде, отдельный гейт | владелец | когда-нибудь | отдельное решение | D22.7, CURRENT-STATE (оркестратор) | @@ -28,16 +28,12 @@ | 13 | Включение платной петли ремонта + per-class исходы ремонта (схемное решение принимается вместе) — вернуться при расширении классов детекторов и ненулевом остатке | владелец | когда-нибудь | отдельное решение | D39.38, D39.39(а), CURRENT-STATE | | 13а | Веса рубрики K1–K12 не выбраны (Q1: «по данным фазы B»; фаза B прошла на BWS без весов — выбор нужен к агрегатной приёмке качества книги) | владелец | когда-нибудь (к пилоту) | Ф2.5 / отдельное решение | D39.44 Q1, D39.46 | | 13б | `speech-cue.txt` zh НЕ шипить до замера голос-флаггера на холодном прогоне (данные для недоказанного детектора = инерция; вывод обсуждения 31.07): оси пустые → файл не нужен, оси ловят → решение владельца о шипе (байты двигают `LangpackVersion`) | владелец | отложено-до-замера | холодный мини-прогон → отдельное решение | D39.56, PACK19_BUILD §7.2, чат 31.07 | -| **— ХОЛОДНЫЙ МИНИ-ПРОГОН (после пака-19) —** | | | | | | -| 16 | Холодный старт с пустым банком — единственная неизмеренная цифра цены/покрытия | бэкенд/полигон | блокер-очереди | холодный мини-прогон | D39.50 п.7, D39.51, POLYGON_PREMEASURE §5 | -| 17 | Совместный recall майнер∪банкнота против сида живьём (замер 1 пакета-8 оказался неизмерим — цензура черновиков) | полигон | блокер-очереди | холодный мини-прогон | D39.48, D39.51, POLYGON_PREMEASURE §1.2 | -| 18 | `allow_short: true` для подписанных 蛊/转 — не доезжают до модели ни одним каналом (правка данных сида) | бэкенд | блокер-очереди | холодный мини-прогон | D39.50 п.5, D39.51, POLYGON_PREMEASURE §1.5 | -| 19 | Фикс парсера банкноты `hasBankSrcHan` (канал мёртв для не-CJK; пар-слепое правило «непуста и встречается в книге») + ветка `dispositionFor`/S7 той же оси | бэкенд | блокер-очереди | холодный мини-прогон | D39.53, PACK20_BANKNOTE_BUILD §5, PACK20_BANK_BUILD §8.5.5 | -| 20 | Остальные вопросы D39.51: банкнота без цензуры · язык при пустом якоре (OffLanguage как прибор) · второй сэмпл для агрегации | бэкенд/полигон | блокер-очереди | холодный мини-прогон | D39.51, D39.53 | -| 21 | Пере-замер P1 consistency на популяции без цензуры ($0 из чекпойнтов) — первый замер провален вырожденностью | полигон | блокер-очереди | холодный мини-прогон | D39.52 | -| 22 | Пере-мер весов формулы консолидации §C2-3 (сейчас n=1, часть отличий — шум модели) | полигон | блокер-очереди | холодный мини-прогон | D39.45, D39.53, PACK20_BANK_BUILD §8.5.7 | -| 23 | Счёт «утечки алфавита» (категория A: 甲等→«категория A») живьём | полигон | блокер-очереди | холодный мини-прогон | D39.52, D39.53 | -| 24 | Именованные вопросы D39.40: покрытие WHAT→WHICH при непустом пересечении · оба режима флага · срабатывания голос-флаггера · эхо-метрика на живом эхо | бэкенд | блокер-очереди | холодный мини-прогон | D39.40, D39.37 | +| **— ХВОСТЫ ХОЛОДНОГО ПРОГОНА (исполнен достаточно, D39.58; Р7 владельца) —** | | | | | | +| 16 | Полная цена холодного старта: edit-волна не гонялась (Р7); измерено всё ДО редактуры (драфт+банкнота $0.0247/волна · терминолог $0.006–0.013/проход) | бэкенд | когда-нибудь (следующий живой прогон) | следующий прогон | D39.58 | +| 18 | `蛊` не приходит НИ ОДНИМ каналом (модель объявляет составные, майнер режет по длине) — структурная дыра эмиссии на голове частотного понятия; доставка подписанной короткой строки проверена $0-пином | бэкенд | когда-нибудь | отдельное решение (рядом с 36а) | D39.58 | +| 21 | Пере-замер P1 consistency — сырьё снято холодным прогоном (4 сэмпла, 150 термов, без цензуры), метод полигонский | полигон | скоро | полигон-пакет | D39.52, D39.58 | +| 22 | Пере-мер весов консолидации §C2-3 при n≥2 — сырьё то же | полигон | скоро | полигон-пакет | D39.45, D39.58 | +| 24 | Остаток: авто-режим флага (проект Б не гонялся) · оси голоса A–D (профили не подписаны; черновик готов — `coldrun-a/SIGN-PACKAGE.md` §3) | бэкенд | когда-нибудь (следующий живой прогон) | следующий прогон | D39.40, D39.58 | | **— ПАК-21 «чекеры» —** | | | | | | | 25 | 12 дефектов чекеров с file:line (супрессор K5c · атрибуция K4b · inner_marker на тире · предохранитель K5a · заглавные/гомоглифы K2 · 两 в cheng_re · U+0301 в TokenizeCyrillic · мн.число в сид-тулинге · однознаковые ключи пост-чека) — приёмка против набора пакета-6 за $0 | бэкенд | скоро | пак-21 | D39.39(в), D39.40 | | 26 | Переезд `translitInterjections` из Go в данные (`cheapgates.go:367`, улов владельца) | бэкенд | скоро | пак-21 | D39.40 | @@ -48,7 +44,7 @@ | 30 | Свип «стартовый промт + гипотезы ресёрчей 01–22 против построенного» ($0, аудит-воркфлоу класса D39.34) | оркестратор | скоро | свип гипотез | D39.38 | | 30а | Перерисовка puml-диаграмм под пост-пак-20 пайплайн (терминолог, банкнота отдельным файлом); PUML НЕ рендерить (гардрейл) | оркестратор | когда-нибудь | док-пасс | аудит доков 26.07 | | 30б | Снять «ИНТЕРИМ»-шапку промпта терминолога (фаза B закрыта: узкое правило + строка примера = текущий текст) | бэкенд | когда-нибудь | ближайшее касание | D39.44 п.6, D39.52/53 | -| 30в | Идеи START_PROMT не разобраны: V4 п.4–5 (сборка глоссария — частично закрыта паком-20; ЯЗЫК промпт-запросов по паре: для zh→en русский не должен участвовать) → в свип; V5 (фронт веб-первый, SEO/ранжирование, куки, страницы-сироты) → Ф3 | оркестратор/владелец | скоро (п.4–5 в свип) | свип гипотез + Ф3 | START_PROMT.MD V4/V5 | +| 30в | Идеи START_PROMT не разобраны: V4 п.4–5 (сборка глоссария — частично закрыта паком-20; ЯЗЫК промпт-запросов по паре) → в свип; V5 (фронт веб-первый, SEO) → Ф3; эксперимент «язык промптов» ОФОРМЛЕН (D39.58 §6.4: армы ru/en при неизменных инъекциях, детерминированные счётчики формата, ≈$0.02–0.05) — к выдаче полигону по слову владельца | оркестратор/владелец | скоро | свип гипотез + полигон-пакет + Ф3 | START_PROMT.MD V4/V5, D39.58 | | **— ОТДЕЛЬНЫЕ ПАКИ/РЕШЕНИЯ (бэкенд, добор идеала) —** | | | | | | | 31 | Слой-2: извлечение дискурс-норм/few-shot из ТЕЛ промтов (форсинг = ja→ru) | бэкенд | когда-нибудь | отдельный пак (идеал D39.33) | CURRENT-STATE, D39.33 | | 32 | ru-target долг слоя 7 (`isRuTarget`×11 · `TokenizeCyrillic`×6 · ключ «ru» в санитайзере) — ждёт своего дизайн-пака | бэкенд | когда-нибудь | отдельный пак (идеал) | CURRENT-STATE, D39.34 | @@ -57,7 +53,11 @@ | 34а | Экран кодировки не видит `` без XML-объявления (епаб с gb18030-метой прочтётся как UTF-8) — до-паковая дыра, сознательно не тронута пачкой 14а (паритет со старым ридером) | бэкенд | когда-нибудь | отдельное решение | D39.54, SMALLPACK §8 | | 35 | Не-CJK майнер/банк: en-детектора не существует (hanRuns=0), ja с zh-таблицами активно неверен (0/10); нужен второй ДЕТЕКТОР (прототип `mine_nonhan.py`) + G1–G10 требования generic-майнера (вкл. квадратичность G9 ≈4.6 ч) | бэкенд | когда-нибудь | отдельный пак generic-майнера (с ja→ru) | D39.37(8), D39.43, D39.50 п.8, POLYGON_PREMEASURE §6 | | 36 | Кластеризация майнера глотает родовые титулы (族长/学堂家老 в кластере 葛家) — закрыто на границе сборки входа, сама кластеризация не чинилась | бэкенд | когда-нибудь | отдельное решение | D39.43, D39.45 | -| 37 | `AttachKWIC` квадратичен (16.8 с / 2000 кандидатов; фикс — многошаблонный поиск за проход) | бэкенд | когда-нибудь | отдельное решение | D39.45, PACK20_BANK_BUILD §8.5.1 | +| 36а | Валидация поля `type` терма — НЕПРОВЕРЕННАЯ третья колонка черновой модели решает ветку транскрипции (`元石`=name ⇒ «юаньши»; механизм «слишком китаизированно»); дешёвые экраны: сверка с классификатором майнера · экран «name с многословным строчным dst» · требование обоснования от роли | бэкенд | скоро (перед следующим банком) | малый дизайн (Р3, приоритет сессии) | D39.58 | +| 36б | Второе мнение по банку: рецензент ДРУГОЙ моделью, несогласия — отдельной колонкой подписной таблицы (судья-с-декоем в роли рецензента; решает владелец, D39.46-ограничение не бьёт) | бэкенд/полигон | когда-нибудь | отдельный дизайн (Р4) | D39.58, D39.51 п.4 | +| 36в | Консистентность СЕРИЙ термов не обеспечена (甲等→«класс Цзя» при 乙等→«ранг И» — роль не видит серии, батчи режут) | бэкенд | когда-нибудь | с дизайном 36а/36б | D39.58 | +| 36г | Проводной формат каналов пересказан прозой в каждом пар-промпте, парсер в Go, связи нет — формат в шаблон движка (`{{banknote_format}}`); линты держат класс, дублирование остаётся | бэкенд | когда-нибудь (со следующей правкой промптов) | отдельное решение (Р5) | D39.58 | +| 37 | Квадратичности: `AttachKWIC` (16.8 с / 2000 кандидатов; фикс — многошаблонный поиск) + src-правило банкноты при промахе чанк-пути (строка × длина книги; на 10 главах не видна) | бэкенд | когда-нибудь | отдельное решение | D39.45, PACK20_BANK_BUILD §8.5.1, D39.58 | | 38 | Смета не видит пере-покупку прохода терминолога на каждой итерации подписи (роль не пишет `chunk_status`, не видна `projectRebill`) | бэкенд | когда-нибудь | отдельное решение | D39.45, PACK20_BANK_BUILD §8.5.2 | | 39 | S12 частичен: `malformedSeparatorAt` не ловит подмену слова разделителя и обрыв токена (комментарий обещает) | бэкенд | когда-нибудь | отдельное решение | D39.45, PACK20_BANK_BUILD §8.5.3 | | 40 | `bankTokenBudget`: worst-case зашит zh→ru (признанная утечка §0; CJK-цель недобирает вдвое) | бэкенд | когда-нибудь | отдельное решение | D39.45, PACK20_BANK_BUILD §8.5.4 | @@ -102,6 +102,10 @@ Отчёт: `archive/reports/TOS_RECHECK_2026-07-31.md`; факты — секция «Право по ToS/AUP» в `experiments/00-provider-quirks.md` (помечена датой ре-чека). **Триггер сработал буквально** — головной Google ToS сменил ревизию `May 22, 2024` → **`Effective July 30, 2026`**, — **но по нашей оси он пуст:** свой байтовый диф архивной и живой US-версий даёт 72 строки, все потребительско-правовые (сетевые расходы, дисклеймеры, калифорнийское право), а счётчики `violen*` = 0 и `sexual*` = 0 в ОБЕИХ ревизиях. Единственная контентная вставка — `Some content may not be suitable for everyone.` в разделе о генерируемом контенте: дисклеймер о ВЫХОДЕ Google, по D39.29 права не даёт, и специально помечена, чтобы её не приняли за сдвиг. **Пере-сняты пять вендоров цепочки — `gemini` · `mistral` · `xai` · `deepseek` · `zai`, все на ПРЕЖНИХ объявленных ревизиях, 34 цитаты подтверждены счётом = 1, дельт вердиктов ноль.** `kimi`/`openai` (403 Cloudflare, JS-шелл) вне названного промтом фокуса — помечены непере-проверенными. **Три находки сверх задачи.** (1) **Триггер поставлен на косвенно привязанный документ:** договор Gemini API — это `Google APIs ToS` (*Last modified: November 9, 2021*, прежде в записи ОТСУТСТВОВАЛ) + `Additional ToS` (*March 23, 2026*) → PUP; потребительский ToS цепляется лишь через API ToS §b `You will not violate any other terms of service with Google`. Предложение: мониторить три документа цепочки, а не `policies.google.com/terms`. (2) **Метод верификации цитат дефектен:** `grep -c -F` по стриппнутому тексту дал ЛОЖНЫЕ НУЛИ на трёх верных цитатах DeepSeek (жёсткие переносы + кавычки в отдельных тегах) — без поправки ре-чек показал бы фантомную дельту вендора; рабочая форма (сверка по копии без пробелов) вписана в quirks. Там же: **`r.jina.ai` как второй путь МЁРТВ (403)**, заменён двумя независимыми снимками Wayback с честной оговоркой «оба в прошлом»; **`ai.google.dev` больше не требует Wayback** — петля была на cookie `signin=autosignin`, лечится cookie-jar. (3) **Рассинхрон:** клетка `gemini` × `sexually-explicit` в quirks стояла `FORBIDDEN` против ратифицированного D39.32 п.2 `UNCLEAR` — помечена ссылкой на амендмент, молча не переписана. **Рекомендация по строке 6 (Gemini в `accepts_labels`): не подписывать сейчас** — ре-чек не дал новой фактуры ни за, ни против, а главное: подпись даст ПРАВО, но не даст судью 18+, потому что по оси ПОВЕДЕНИЯ Gemini fail-closed на графичной эротике (D22.6, 8× wire-verified, фильтр неконфигурируем), а Additional ToS теперь ещё и прямо запрещает обход фильтра (`You may not attempt to bypass these protective measures`). Строка стоит дешевле, чем выглядит: ничего не блокирует и почти ничего не открывает. Следующий триггер — квартальный **25.10.2026**, без изменений. Сессия не коммитила; `accepts_labels` не трогала; бэклог не правила (зона оркестратора). +## Оркестратор №9 + бэкенд — ХОЛОДНЫЙ МИНИ-ПРОГОН ПРИНЯТ И ЗАЛЕНДЕН (D39.58): recall холодного банка 0.918/0.980, эхо-мина жива при thinking-ON, 31.07 + +Прогон закрыт владельцем после банк-стопа (Р7: замер достаточен, ~$0.12 сэкономлено). Главное: банк с нуля восстанавливает 45+3 из 49 подписанных термов и приходит владельцу ПЕРЕВЕДЁННЫМ (149/150) — против recall 0.069–0.103 у майнера соло. Построено до трат: пар-слепой парсер банкноты (строка 19), разделение версий канала (Р2, амендмент инварианта №2), S7 по провенансу (путь ii, экспозиция 0 на 1119 парах), линты промптов. П3 пре-регистрации сработал: эхо 2/20 при thinking-ON — гейт поймал, эскалация вылечила, факт в quirks. Деньги $0.126068, spend ≡ Σ checkpoints; брак+переработка $0.056 названы, из них норма «промпты итерировать на пробе 1–2 глав». Бэклог: 17/19/20/23 закрыты, 18 переформулирована (蛊 — структурная дыра эмиссии), +36а–36г (валидация type — приоритет · рецензент банка · серии · формат в шаблон). Отчёты: `COLDRUN_REPORT_2026-07-31.md` + `COLDRUN_HANDOFF_2026-07-31.md`. + ## Оркестратор №9 + полигон — ToS-РЕ-ЧЕК ПО ТРИГГЕРУ ИСПОЛНЕН (D39.57): дельт вердиктов нет, 31.07 Головной Google ToS сменил ревизию (30.07.2026), но по оси лейблов пуст; пять вендоров пере-сняты — вердикты не двигаются. Находки метода: триггер висел не на том документе — перевешен на цепочку договора (PUP · Additional ToS · Google APIs ToS, последний добавлен в запись); рассинхрон quirks-клетки gemini×SE с D39.32 закрыт; правило «двух путей» амендировано (jina мёртв). Строка 6 понижена в приоритете (подпись ≠ судья 18+). Параллельно бежит холодный мини-прогон (бэкенд). Отчёт: `archive/reports/TOS_RECHECK_2026-07-31.md`. diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index f928ffd7..19d97866 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -1183,4 +1183,8 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу ## D39.57 — ToS-РЕ-ЧЕК ПО ПЕРВОМУ КАЛЕНДАРНОМУ ТРИГГЕРУ ИСПОЛНЕН ($0, полигон, 31.07): головной Google ToS реально сменил ревизию (Effective July 30, 2026), но по оси `violence`/`sexually-explicit` дельта ПУСТА — все пять пере-проверенных вендоров (gemini/mistral/xai/deepseek/zai) на прежних ревизиях, вердикты не двигаются, `accepts_labels` не трогаются · ПРЕДМЕТ Google-мониторИНГА ПЕРЕВЕШЕН на цепочку договора (PUP · Gemini API Additional ToS · Google APIs ToS — потребительский ToS цепляется лишь косвенно и по нашей оси пуст) · в запись добавлен пропущенный головной Google APIs ToS (по оси пуст) · рассинхрон quirks-клетки `gemini`×`sexually-explicit` с D39.32 п.2 закрыт (FORBIDDEN → UNCLEAR, прозрачной правкой) · правило «двух путей» пакета-5 амендировано (r.jina.ai мёртв — 403; замена: Wayback ×2 снимка + прямой curl с cookie-jar; проверка цитат — по копии без пробелов, старая форма давала ложные нули) (31.07, оркестратор №9). ✅ -Существенное для судьи Ф2: Additional ToS прямо запрещает обход фильтра (`You may not attempt to bypass these protective measures`) — смыкается с D22.6 (фильтр неконфигурируем) ⇒ подпись под строкой 6 дала бы ПРАВО отправки, но НЕ работающего судью эротики; судья 18+ остаётся grok, строка 6 аннотирована и понижена в приоритете (возвращаться при собственном вопросе, требующем именно права). Следующие триггеры: квартал 25.10.2026 · первая лейблованная книга · любая правка `accepts_labels`. Отчёт: `archive/reports/TOS_RECHECK_2026-07-31.md` (ревью-шапка). Мутации сессии 23/23 красные (две выжившие первого круга = дыры тестов, закрыты с премис-проверками). Критик полноты честен: оси до холодного прогона дают 0 срабатываний («не измерено», не «чисто») · ось A может ловить цитату · ось B precision-over-recall · качество осей = функция полноты `decl` сида · SourceReplies без выравнивания не сопоставим с Replies. Хвосты: бэклог 13б (`speech-cue.txt` — владельцу) · 49а (ALTER-миграции v8–v14 не идемпотентны вопреки шапке `migrate.go:9-11`) · golden-счётчики голоса — кандидат следующего ратифицированного пере-капчера. Первое измерение = `gates.voice` ON на холодном мини-прогоне (бэклог 24). Отчёт: `archive/reports/PACK19_BUILD_2026-07-30.md` (ревью-шапка). +Существенное для судьи Ф2: Additional ToS прямо запрещает обход фильтра (`You may not attempt to bypass these protective measures`) — смыкается с D22.6 (фильтр неконфигурируем) ⇒ подпись под строкой 6 дала бы ПРАВО отправки, но НЕ работающего судью эротики; судья 18+ остаётся grok, строка 6 аннотирована и понижена в приоритете (возвращаться при собственном вопросе, требующем именно права). Следующие триггеры: квартал 25.10.2026 · первая лейблованная книга · любая правка `accepts_labels`. Отчёт: `archive/reports/TOS_RECHECK_2026-07-31.md` (ревью-шапка). + +## D39.58 — ХОЛОДНЫЙ МИНИ-ПРОГОН ПРИНЯТ И ЗАЛЕНДЕН ($0.126068 из $0.50; закрыт владельцем после банк-стопа, Р7 «замер достаточен»): ГЛАВНОЕ — совместный recall холодного банка майнер∪банкнота = **0.918/0.980** против 0.069–0.103 у майнера соло, банк приходит владельцу ПЕРЕВЕДЁННЫМ (149/150 строк с dst) · банкнота-парсер v2 ПАР-СЛЕПОЙ («src непуст и засвидетельствован книгой», строка 19 закрыта) + восстановление порядка колонок тем же инвариантом · РАЗДЕЛЕНИЕ ВЕРСИЙ КАНАЛА ратифицировано (Р2, амендмент инварианта №2: `bankSliceVersion` фолдится — его выход оплачен чекпойнтом; `bankParseVersion` едет с прогоном — его выход пересчитываемые улики) · секция редактора по ПРОВЕНАНСУ (S7), путь (ii) — экспозиция 0 доказана диффом (1119 пар «запись×ключ», 32 банка, позитивный контроль; версии не бампались) · ЭХО-МИНА ЖИВА при thinking-ON (П3 пре-регистрации: 2/20 черновиков чистым исходником, han_share 0.999; гейт `cjk_artifact` поймал, эскалация вылечила; «echo 0» D39.20 — свойство выборки, не гарантия) (31.07, оркестратор №9). ✅ + +Прочее прогона: язык при пустом якоре — `off_language` 0/149 (экран-прибор работает) · утечка алфавита 0/150, но `甲等 → класс Цзя` при `乙等 → ранг И` вскрыла два механизма: НЕПРОВЕРЕННОЕ поле `type` решает ветку транскрипции (`元石`=name ⇒ «юаньши» — механизм «слишком китаизированно») и роль не видит СЕРИЙ (батчи режут) · `蛊` не приходит НИ ОДНИМ каналом (строка 18 переформулирована: структурная дыра эмиссии на голове частотного понятия; механизм доставки подписанной короткой строки проверен $0-пином) · эхо-метрика закрыта живым фактом (2 recovered) · стоп-режим ×4; авто-режим, edit-волна, полная цена (16), P1/веса (21/22 — сырьё снято, метод полигонский), оси голоса (профили не подписаны; черновик в `coldrun-a/SIGN-PACKAGE.md` §3; 13б открыта) — со следующим живым прогоном/полигон-пакетом. **Принятые нормы (в промт-нормы оркестратора):** итерации промптов — на проекте-пробе 1–2 главы (≈$0.005), полный срез после; правка-«терпимость» движка к собственным данным = воркэраунд (правило владельца 31.07); канон конкретной книги в пар-промпте = утечка §0 (поймано владельцем). Новые строки бэклога: 36а валидация `type` (Р3, приоритет) · 36б второе мнение-рецензент (Р4; условие строки 5 наступило) · 36в серии · 36г формат каналов в шаблон движка (Р5) · квадратичность src-правила → строка 37. Эксперимент «язык промптов» (Р6, гипотеза владельца, ≈$0.02–0.05, детерминированные счётчики) — к выдаче полигону по слову владельца. Отчёты: `archive/reports/COLDRUN_REPORT_2026-07-31.md` (пре-регистрация §0 до трат · критик полноты §9 · ревью-шапка) + `COLDRUN_HANDOFF_2026-07-31.md`. Мутации сессии 23/23 красные (две выжившие первого круга = дыры тестов, закрыты с премис-проверками). Критик полноты честен: оси до холодного прогона дают 0 срабатываний («не измерено», не «чисто») · ось A может ловить цитату · ось B precision-over-recall · качество осей = функция полноты `decl` сида · SourceReplies без выравнивания не сопоставим с Replies. Хвосты: бэклог 13б (`speech-cue.txt` — владельцу) · 49а (ALTER-миграции v8–v14 не идемпотентны вопреки шапке `migrate.go:9-11`) · golden-счётчики голоса — кандидат следующего ратифицированного пере-капчера. Первое измерение = `gates.voice` ON на холодном мини-прогоне (бэклог 24). Отчёт: `archive/reports/PACK19_BUILD_2026-07-30.md` (ревью-шапка). diff --git a/docs/archive/reports/COLDRUN_HANDOFF_2026-07-31.md b/docs/archive/reports/COLDRUN_HANDOFF_2026-07-31.md new file mode 100644 index 00000000..369cc63d --- /dev/null +++ b/docs/archive/reports/COLDRUN_HANDOFF_2026-07-31.md @@ -0,0 +1,56 @@ +# Хендофф оркестратору: холодный мини-прогон (бэкенд-сессия 31.07) + +**Статус:** сессия закрыта владельцем ПОСЛЕ банк-стопа, до подписи и edit-волны. Дерево НЕ закоммичено. Факты и числа — `COLDRUN_REPORT_2026-07-31.md` (§0 пре-регистрация написана до первой траты, §9 — критик полноты). Здесь только то, что нужно для лендинга и решений. + +## 1. Что лендить + +11 изменённых файлов бэкенда + 2 новых теста. Содержательно — пять вещей: + +1. **Правило src банкноты пар-слепое** (бэклог 19 закрыт): «исходная строка непуста и встречается в книге» вместо ханьского диапазона. Канал перестал быть мёртвым для не-CJK и начал отсекать выдуманные поверхности. +2. **Восстановление порядка колонок** тем же инвариантом — не терпимость, а определение: сторона исходника та, которую свидетельствует книга; объявленный порядок побеждает всегда, когда засвидетельствован. +3. **Разделение версий канала** — `bankSliceVersion` фолдится (его выход = оплаченный чекпойнт), `bankParseVersion` едет с прогоном (его выход = улики, пересчитываются каждым прогоном). Слитая версия штрафовала за починку правила разбора полной перекупкой черновой волны. +4. **Секция редактора по провенансу** (S7): подписанная владельцем строка больше не попадает в секцию «никем не утверждено» из-за коллизионного ключа. `Ambiguous` остался стражем матча. +5. **Два линта пар-пакетов промптов** + правки данных: формат каналов ПОКАЗАН настоящей табуляцией. Линт нашёл второй экземпляр дефекта, которого никто не искал. + +**Приёмка сессии:** мутации 12/12 красные (одна выжила на первом круге → дыра теста закрыта пином → красная), сьюта `-race` 14/14, vet/gofmt чисты, **golden не пере-капчен и не покраснел**, стендовые артефакты байт-неподвижны (33 хеша). + +## 2. Решения, которые нужны + +| # | Решение | Готовность | +|---|---|---| +| Р1 | **Ратифицировать путь (ii) по S7** — версии не бампались, экспозиция строго ноль (1119 пар «запись×ключ» на 32 банках, позитивный контроль рабочий). Владелец санкционировал заранее при условии нуля; условие выполнено и доказано диффом. | к подписи | +| Р2 | **Ратифицировать разделение версий канала** — это правка контракта инварианта №2 (что именно фолдится). Прецедент в кодовой базе: `gates.terminology`/`gates.voice` не фолдятся по той же причине. | к подписи | +| Р3 | **Валидация поля `type`** — оно решает «транслитерировать или переводить», приходит третьей колонкой от черновой модели и не проверяется ничем. Механизм жалобы владельца «слишком китаизированно» (`元石` пришёл типом `name` ⇒ «юаньши»). Дизайн — центы. | нужен дизайн | +| Р4 | **Второе мнение по банку** (архитектура глав: две разные модели). Отложенный судья-с-декоем в роли РЕЦЕНЗЕНТА: несогласия отдельной колонкой в подписной таблице, решает владелец. Условие возврата («после холодного старта») наступило. | нужен дизайн | +| Р5 | **Формат каналов — в шаблон движка** (`{{banknote_format}}`), пар-пакет несёт только язык и обёртку. Сегодня контракт пересказан прозой в каждом пар-промпте, парсер живёт в Go, связи нет. | нужен дизайн | +| Р6 | **Эксперимент «язык промптов»** (гипотеза владельца 31.07, строка 30в): арм A ru / арм B en при неизменных инъекциях, метрика — детерминированные счётчики формата, судья не нужен. Зона полигона, ≈$0.02–0.05. | к выдаче | +| Р7 | **Довести ли холодный прогон** (подпись → edit-волна → авто-режим проекта Б, ≈$0.12) или считать замер достаточным. Владелец склонился ко второму; фиксирую как решение, а не как незакрытый хвост. | принято владельцем | + +## 3. Дельта бэклога + +**Закрыты:** 17 (совместный recall — **0.918/0.980** против 0.069–0.103 у майнера соло) · 19 (парсер) · 23 (утечка алфавита — 0 из 150) · часть 20 (язык при пустом якоре: `off_language`=0 на 149 консолидациях; четыре сэмпла банкноты) · часть 24 (эхо-метрика на живом эхе; покрытие WHAT→WHICH при непустом пересечении — 14 «оба канала»). + +**Переформулированы:** 18 — механизм доставки односимвольного терма проверен $0-пином, но `蛊` **не приходит ни одним каналом никогда** (модель объявляет составные, майнер режет по длине) ⇒ это не «правка данных сида», а структурная дыра эмиссии на голове частотного понятия. + +**Остаются открытыми:** 16 (полная цена холодного старта — edit-волна не гонялась) · 21/22 (сырьё есть, метод полигонский) · 13б (`speech-cue.txt` — оси голоса «не измерено», см. ниже) · остальное 24 (авто-режим). + +**Новые строки:** +- Тип терма не валидируется, а решает ветку транскрипции (Р3). +- Консистентность серий не обеспечена: `甲等 → класс Цзя` против `乙等/丙等/丁等 → ранг И/Бин/Дин` — одна серия, два родовых слова, потому что термы в разных батчах роли. +- Проводной формат каналов дублируется в пар-промптах (Р5); линты держат класс, дублирование остаётся. +- Квадратичность правила src при промахе чанк-пути (рядом со строкой 37). +- **Эхо-мина жива при thinking-ON** — 2/20 черновиков вернулись чистым исходником; гейт поймал, эскалация вылечила. «echo 0» пере-прогона (D39.20) был свойством выборки, а не гарантией. + +**Строка 13б осталась неразрешимой этим прогоном** и по моей вине частично: `gates.voice` был включён, но реестр флаггера собирается ТОЛЬКО из `voices:`/`addresses:` сида — при пустом сиде оси пусты по построению. Я поднял это в синке до трат (Q1), владелец санкционировал мини-профили при подписи, но подписи не было. Черновик профилей заземлён на текст среза и лежит в `coldrun-a/SIGN-PACKAGE.md` §3 — готов к любому следующему прогону. + +## 4. Мои ошибки (методические, для нормы следующего промта) + +1. **$0.010064 сожжено** на оборванной волне: начал пере-драфт со старым промптом и остановил, когда пришло правило владельца о воркэраундах. **$0.046341 — переработка**: разделение версий канала надо было спроектировать ДО первой платной волны. Норма среза — одна волна $0.0247; заплачено четыре. +2. **Первую починку `` я сделал терпимостью парсера** — то есть спрятал дефект наших же данных в движок. Владелец это назвал, и он прав; откатил, починил корень, поставил линт над классом. Урок в норму: правка, которая делает движок терпимым к нашему собственному тексту, — почти всегда воркэраунд. +3. **В пар-промпт я вписал канон конкретной книги** (`方源 → Фан Юань` как пример формата). Это ровно то, что норма §0 запрещает; поймал владелец, не я. Заменено на нейтральное `师父 → наставник`. + +**Процессное предложение из этого опыта:** байты промпта фолдятся в снапшот черновой волны, поэтому каждая итерация промпта стоит полную волну. Итерировать промпты надо на проекте-пробе в 1–2 главы (≈$0.005), и только потом покупать полный срез. Этого нет ни в одной норме, а цена ошибки — вся волна. + +## 5. Одно суждение + +Прогон ответил на свой главный вопрос лучше ожидаемого: холодный банк восстанавливает 45 из 49 подписанных термов и приходит к владельцу **полностью переведённым** (149 из 150 строк с dst), а не голым — контраст с тёплым прогоном 25.07, где владелец получил голые исходные термы. Слабое место оказалось не в покрытии, а там, где его никто не мерил: **банк, который потом связывает каждую главу книги, переводится одним проходом самой дешёвой модели без второго мнения**, а решение «транслитерировать или переводить» принимается по непроверенному полю, которое та же модель дописала третьей колонкой. Если из всего списка выбирать одно — я бы взял Р3 (валидация типа), а не Р4: второе мнение без валидации типа будет спорить о переводе там, где спорить надо о классе. diff --git a/docs/archive/reports/COLDRUN_REPORT_2026-07-31.md b/docs/archive/reports/COLDRUN_REPORT_2026-07-31.md new file mode 100644 index 00000000..52fadda9 --- /dev/null +++ b/docs/archive/reports/COLDRUN_REPORT_2026-07-31.md @@ -0,0 +1,201 @@ +# Холодный мини-прогон: банк с нуля (пустой сид, отдельная БД) + +**Сессия:** бэкенд, 31.07.2026. **Промт:** `docs/BACKEND_COLDRUN_SESSION_PROMPT.md` (оркестратор №9, санкция владельца). **Релей 31.07:** потолок расширен до **$0.50**; Q1 — мини-профили голоса в подписную таблицу; Q2 — сначала замер экспозиции, ноль ⇒ путь (ii) без бампа версий с доказательством диффом. **Сессия НЕ коммитит.** + +> **Ревью-шапка (оркестратор №9, 31.07): ПРИНЯТО И ЗАЛЕНДЕНО, D39.58; прогон закрыт владельцем после +> банк-стопа (Р7: замер достаточен, edit-волна и авто-режим — со следующим живым прогоном).** Короткая +> приёмка: сьют `-race` перегнан оркестратором — зелёный; golden/testdata неподвижны (дифф 0); `repin.go` +> не тронут; дифф `snapshot.go` +8/−5 — только фолд-блок банкноты (разделение версий канала, ратифицировано +> Р2). Путь (ii) по S7 ратифицирован: экспозиция 0 на 1119 парах × 32 банках с рабочим позитивным контролем. +> Девиация от буквы промта («не трогать snapshot.go») ратифицирована постфактум: изменение и есть предмет +> Р2, поднято хендоффом открыто; процессный нит — пинг ДО правки был бы правильнее. Деньги: $0.126068, +> `spend ≡ Σ checkpoints`; брак+переработка $0.056 названы честно, из них принята норма «итерации промптов — +> на проекте-пробе 1–2 главы». Эхо-факт (П3) внесён в quirks строкой оркестратора. + +--- + +## §0. ПРЕ-РЕГИСТРАЦИЯ (написана ДО первой платной траты; ниже НЕ переписывается) + +Причина, по которой это первый раздел: по переписи инцидентов проекта самоотчёт — доминирующий канал ошибок, а прогон, чей критерий успеха дописан после результата, не доказывает ничего (урок мини-прогона 25.07, хендофф §4.4: критерий, который потом приходится объяснять, даёт ложное спокойствие). + +### 0.1 Конфигурация — заморожена + +| Ось | Значение | Почему так | +|---|---|---| +| Срез | главы 1–10, 57 838 байт GB18030, sha256 `0b5f9b02…c89f37` | байт-копия среза тёплого мини-прогона 25.07, независимо пере-деривирована из книги (строки 93–837) — три хеша совпали ⇒ разница «тёплый↔холодный» есть разница конфигураций | +| Сид | **ПУСТОГО ключа `glossary_seed` в `book.yaml` нет** | холодный старт; подписанный сид стенда — эталонный ключ офлайн-замера (бэклог 17), в БД не грузится | +| БД/каталог | `~/books/gu-zhenren/coldrun-a/` (проект А), `coldrun-b/` (проект Б) | стендовые БД не трогаются; их хеши сняты ДО прогона (33 артефакта) | +| Каналы | банкнота ON · терминолог ON (`target_script: Cyrillic`) · `gates.voice` ON · санитайзер ON · регресс-гард ON · coverage OFF · петля ремонта OFF | первое живое включение трёх каналов вместе | +| Флаг | проект А — `--verify-bank` (стоп на подпись), проект Б — без флага (авто-провод) | оба режима, бэклог 24 | +| Потолки | А: book/day $0.25 · Б: $0.08 | сумма $0.33 при смете $0.154 — гейт способен выстрелить | +| Модели | draft `deepseek-v4-flash` (thinking ON) · edit `deepseek-v4-pro` · терминолог `deepseek-v4-flash` | шиппинговая форма D39.22/D39.42 | +| Окно | вне UTC 01–04 / 06–10 | долины DeepSeek (промт) | + +### 0.2 Смета ДО трат (фриз; источник каждого числа назван) + +| стадия | ожидание | источник | +|---|---|---| +| А: черновая волна (банкнота ON) | $0.022–0.026 | тёплый прогон того же среза: драфт ≈$0.022; проба банкноты draft-only $0.021688 | +| А: терминолог, 2 раунда подписи | $0.004–0.010 | D39.45 §8.5.2 ($0.002–0.005 за проход; каждая итерация подписи покупает проход заново) | +| А: edit-волна | $0.092 | тёплый прогон: главный проход $0.114291 − драфт | +| Б: 2 главы, авто-режим | $0.026 | 1/5 объёма А | +| резерв на брак/ретраи | $0.030 | урок хендоффа §4.1 ($0.046668 сожжено на конфиге, собранном `sed`-ом по чужому `book.yaml`) | +| **итого** | **≈$0.154 из $0.50** | | + +### 0.3 Что прогон обязан подтвердить СЫРЬЁМ (M1–M9) + +| # | Утверждение | Артефакт-носитель | +|---|---|---| +| M1 | Цена и покрытие ХОЛОДНОГО старта по стадиям (майнер / банкнота / терминолог / подпись) | леджер + `request_log` + воронка эмиссии; деньги сверяются двумя независимыми путями | +| M2 | Совместный recall майнер∪банкнота против ПОДПИСАННОГО сида стенда | офлайн-скрипт по `tmctl export` + сид как ключ (в БД прогона его нет) | +| M3 | Банкнота на нецензурированных черновиках: строки, `parse_fail`, воспроизводимость между сэмплами (А vs Б на общих главах) | `retrieval_state.banknote_detail`, сырые ответы | +| M4 | Язык ответа терминолога при ПУСТОМ якоре ⟦TM-CANON⟧ (холодный банк) — счётчик `OffLanguage` как прибор | лог банк-стопа, счётчик | +| M5 | Оба режима флага: стоп→подпись→продолжение (А) и авто-провод с ⟨проверить⟩ (Б) | логи стопа, `*.db.mined-signature.yaml`, `*.db.bank-stop.txt`, тело запроса редактора | +| M6 | `allow_short` на ЖИВОЙ подписи: 蛊/转 доезжают до модели | инъекция в `request_log`/теле запроса | +| M7 | Оси голос-флаггера A–C + индикатор D + `spoiler_leaks` со ЗНАМЕНАТЕЛЯМИ | `retrieval_state.voice_detail` | +| M8 | Утечка алфавита (甲等→«категория A») — счёт живьём | банк + подписная таблица | +| M9 | Деньги durable: `committed ≡ Σ checkpoints ≡ Σ(usage×price)`; потолок способен выстрелить | `spend`, `checkpoints`, `request_log` | + +$0-замеры из чекпойнтов (не требуют отдельных трат): пере-замер P1 consistency на нецензурированной популяции (бэклог 21) · пере-мер весов консолидации §C2-3 при n=2 (бэклог 22) · покрытие WHAT→WHICH при непустом пересечении (бэклог 24). + +### 0.4 ЧТО ЗАРАНЕЕ НАЗЫВАЕТСЯ ПРОВАЛОМ + +| # | Провал (назван до трат) | +|---|---| +| П1 | Главный проход проекта А **> $0.30** (вдвое выше верха сметы) — модель денег неверна для холодного банка | +| П2 | Пробит потолок ИЛИ `committed ≠ Σ checkpoints` вне ратифицированного исключения `redrive` | +| П3 | Эхо-класс (`cjk_artifact`) на черновике **> 0** — эхо-мина жива при thinking-ON | +| П4 | Банкнота: `n_banknote_lines = 0` по ВСЕМ чанкам, ИЛИ `parse_fail > 0` без предъявленной СТРОКИ-виновника (после v2-правила `parse_fail` означает «модель написала src, которого в книге нет» — это факт о модели, и он обязан быть показан текстом, а не счётчиком) | +| П5 | Банк-стоп не остановился под `--verify-bank` при непустой дельте ИЛИ проект Б остановился без флага | +| П6 | Терминолог ответил не в целевом письме, а экран `OffLanguage` этого НЕ увидел (прибор слеп) | +| П7 | Подписанный односимвольный терм (`allow_short: true`) не доехал до модели ни одним блоком | +| П8 | Любой байт стендовых артефактов изменился (33 хеша сняты до прогона) | +| П9 | Число отчитано без второго пути вывода там, где второй путь возможен (норма 30.07: «пере-мерь свой вывод другим способом, чем получил») | + +### 0.5 Что заранее объявлено НЕ провалом + +- **Большой лист подписи.** Холодный банк обязан быть большим; это стоимость холодного старта, а не дефект. +- **Оси голоса B/C = 0, если владелец не подписывает профили** (Q1): при пустых `voices:` реестр пуст ПО ПОСТРОЕНИЮ (`voicerun.go:25-27`, `memvoice.go:343-360`), и тогда честная запись — «не измерено», а не «чисто». То же для `SourceReplies`: zh-`speech-cue.txt` не шипнут (бэклог 13б) ⇒ знаменатель источника = «не измерено». +- **Эха не случилось** — законный исход; эхо-метрика тогда «не измерена» (тёплый прогон закрыл её тестами, но не прогоном). +- **Терминолог предложил dst, который владелец отверг** — это работа подписи, а не дефект роли. +- **Остаток дефектов** на этом прогоне вторичен: банкнота включена в главный проход (в отличие от 25.07), значит черновик другой, и сравнивать остаток с тёплым прогоном напрямую нельзя. +- **`dialogue_dash` на 蛊真人** — загрязнён подгонкой (D39.37 п.4), его цифры аргументом не считаются. + +### 0.6 Стройка, исполненная ДО первой траты (детали — §1) + +Парсер банкноты v2 (пар-слепое правило) · секция редактора по провенансу (S7) · пин канала `allow_short` · мутации 7/7 красные · сьюта `-race` 14/14 зелёная · golden не тронут. + +--- + +## §1. Построено (всё — до первого платного вызова, кроме двух правок промптов, вызванных замером) + +| # | Что | Где | Приёмка | +|---|---|---|---| +| 1 | **Правило src банкноты: «встречается в книге» вместо ханьского диапазона** (бэклог 19) | `pipeline/banknote.go:129-186` (`bankSourceIndex`, `bankSrcAttested`), индекс строится в композит-корне `bookrun.go:141-144` | `TestParseBanknoteSrcMustBeAttested`, `TestBankSourceIndexParity`; мутации M1–M3 красные | +| 2 | **Восстановление порядка колонок** тем же инвариантом (находка прогона) | `banknote.go` (switch по `srcAttested`) | `TestParseBanknoteRecoversColumnOrder`; M9/M10 красные | +| 3 | **Разделение версий канала**: `bankSliceVersion` фолдится, `bankParseVersion` едет с прогоном | `banknote.go:46-64`, `snapshot.go:113-129` | `TestBanknoteSnapshotFold` (пинит и отсутствие парс-версии в payload); M11 красная | +| 4 | **Секция редактора по ПРОВЕНАНСУ, не по disposition** (S7) | `membank/memory.go:786-815` | `TestEditorSectionFollowsProvenance`; M4/M5 красные | +| 5 | **Пин канала `allow_short` сквозь сид→банк→провод** (бэклог 18) | `pipeline/allowshort_wire_test.go` | оба направления (с флагом доезжает, без — нет) | +| 6 | **Линты пар-пакетов промптов** (класс, не экземпляр) | `config/prompt_lint_test.go` | M12 красная (дефект возвращён в данные при целом линте) | +| 7 | Данные: формат банкноты и терминолога ПОКАЗАН настоящей табуляцией; правило «в первом поле — фрагмент исходника» | `prompts/zh-ru/translator-banknote.md`, `terminologist.md` | линт + замер (см. §2.2) | +| 8 | Закрыта дыра тестов: `entryFiringKeys` игнорировал `allow_short` | `membank/memseed_test.go` | M7 (выжила → закрыта → красная) | + +Мутации **12/12 красные**. Сьюта `-race` 14/14, vet/gofmt чисты (`llm.go` — до-паковый нит, не тронут). Golden НЕ пере-капчен и НЕ покраснел. + +**Ответ на Q2 релея — экспозиция S7 = 0, доказано диффом, а не заявлением.** Скан движковыми предикатами по 32 банкам (14 стендовых БД + 18 сид/дельта-файлов): 977 подписанных строк, **1119 пар (запись, срабатывающий ключ)**, `EXPOSED=0`, `DIVERGENT=0` при рабочем позитивном контроле. Равенство старого и нового маршрута на КАЖДОЙ паре сильнее одного отрендеренного блока — оно держится при любой выборке бюджета. ⇒ путь (ii): версии `matchVersion`/`RenderFormatVersion` не бампались, поведение существующих книг байт-идентично. + +## §2. Замеры + +### 2.1 Совместный recall холодного банка против ПОДПИСАННОГО сида (бэклог 17) — главный результат + +Эталон: 49 подписанных термов сида, засвидетельствованных в срезе (4 из 53 в срезе не встречаются — исключены как недостижимые). Найдено холодным банком: **45 точно + 3 внутри составного = 48**. + +> **RECALL совместный = 0.918 строгий / 0.980 с составными.** Против recall одного майнера 0.069–0.103 (D39.43). + +Пропущен ровно ОДИН терм: `长生 → бессмертие`. Оговорки честности: эталон не независим (сид собран с знанием этой книги), сверка — точное вхождение и вхождение-в-составное, без движкового нормализатора (обе стороны — упрощённые ханьцы, расхождение маловероятно, но не исключено). + +### 2.2 Цена и покрытие по стадиям (бэклог 16, частично — edit-волна не гонялась) + +| стадия | факт | ожидание §0.2 | +|---|---|---| +| черновая волна, 20 чанков (одна волна) | **$0.024740** | $0.022–0.026 ✓ | +| терминолог, один проход по холодному банку | **$0.006211 → $0.013045** (растёт с числом кандидатов: 75 → 150) | $0.002–0.005 ✗ занижено | +| эскалация 2 хопа на dspro | $0.014670 | не планировалось | + +**Покрытие канала WHAT по сэмплам** (все перепарсены ОДНИМ текущим правилом ⇒ разница = промпт): + +| сэмпл | промпт | чанков со строками | различных src | отвергнуто строк (новое/старое правило) | +|---|---|---|---|---| +| 1 | старый (`` словом) | 10/20 | 74 | 4 / 28 | +| 3 | таб показан | 13/20 | 95 | 3 / 21 | +| 4 | + «в первом поле — исходник» | **18/20** | 96 | 3 / 5 | + +Новое правило внутри одного сэмпла: теряет 4 поверхности (выдуманные ханьские компаунды `主仆身份`, `四更天` — их в книге нет) и возвращает 51 строку, которую старое правило выбрасывало. Точность и полнота выросли по разным причинам. + +### 2.3 Свод банка + +150 кандидатов (128 только банкнота · 14 оба канала · 8 алиасы кластера); на подпись — 142 (=128+14). **149 из 150 несут перевод**, одна отклонена ролью (`⟦TM-NO-DST⟧` отработал). **75 из 150 (50%) имеют расхождение между черновиками** — 方源 имеет 11 чтений, включая шесть раз имя ДРУГОГО персонажа («Фан Чжэн»); консолидация выбрала верное. Типы: term 95 · name 22 · place 18 · title 13 · nickname 2. + +### 2.4 Экраны и прочие строки бэклога + +- **Язык ответа при ПУСТОМ якоре ⟦TM-CANON⟧ (бэклог 20):** `off_language=0` на 149 консолидациях. Экран как прибор работает, срыва языка на холодном банке нет. +- **Утечка алфавита (бэклог 23): 0 из 150** — латиницы в предложенных dst нет вовсе. Роль ушла в транслитерацию (`甲等 → класс Цзя`), а не в латинскую букву. +- **`allow_short` (бэклог 18):** `转` пришло само (freq 62, «ранг»); **`蛊` не приходит НИКОГДА** — модель объявляет составные (`蛊师`/`蛊虫`/`月光蛊`), но не голову, майнер режет по длине ≥2. Центральное понятие книги в холодный банк без ручной строки не попадает. Механизм доставки подписанного односимвольного терма до провода проверен $0-пином. +- **Эхо-метрика на живом эхо (бэклог 24):** закрыта фактом — `echo draft=2 (10.0%, 2 recovered by escalation)`. +- **Оба режима флага (бэклог 24):** стоп-режим отработан четырежды; **авто-режим НЕ гонялся** (сессия закрыта до проекта Б). +- **Гейт согласия на пере-оплату:** проекция `$0.024740` совпала со списанным ДО ЦЕНТА; при кэпе ниже проекции отказал, не тронув ни строки, ничего не зарезервировав. + +## §3. Сработавшие критерии провала (названы в §0.4 ДО трат) + +**П3 сработал: эхо-мина DeepSeek ЖИВА при thinking-ON.** Два черновика вернулись чистым исходником (`han_share=0.999`, кириллицы ноль; ch5/chunk0, ch9/chunk1 сэмпла 3). Конфиг соответствовал инварианту №3 (`reasoning:"off"` — no-op у deepseek, thinking включён). Механизм отработал как задуман: гейт `cjk_artifact` → эскалация на dspro → корректный перевод, метрика показала оба случая. **Читать как:** thinking-ON снижает частоту эха, но НЕ исключает его; «echo 0» пере-прогона 23.07 (D39.20) был свойством выборки, а не гарантией. + +П4 частично: `parse_fail` держится на 3 чанках из 20 и после всех правок — но это НЕ дефект парсера. Строки предъявлены: модель выдаёт таблицу целиком на целевом языке (обе колонки по-русски) — исходной поверхности в строке нет вообще, банковать нечего. + +Остальные (П1, П2, П5–П9) не сработали. Стендовые артефакты: 33 хеша сняты до прогона, ни один не изменился. + +## §4. Деньги — $0.126068 из потолка $0.50 (потолок проекта $0.25, использовано 50.4%) + +`spend ≡ Σ checkpoints`, сверено `tmctl report`/`status`. Разбивка: черновые волны (4 запуска) $0.081145 flash + $0.014670 pro (эскалации) · терминолог (3 прохода) $0.030253. + +**Мой брак — $0.010064** (оборванная волна: запустил пере-драфт со старым промптом и остановил его через 10 вызовов, когда пришло правило владельца «не закрывать баги воркэраундами»). **Переработка — $0.046341** (волны 3 и 4): она купила разложение «промпт против парсера» и четыре сэмпла, но её причина — моя ошибка последовательности: разделение версий канала (§1 п.3) надо было спроектировать ДО первой платной волны, а не после неё. Норма прогона на этом срезе — одна волна $0.0247; мы заплатили четыре. + +## §5. Найдено и НЕ починено (с ценой решения) + +1. **Банк переводит ОДНА дешёвая модель одним проходом, без второго мнения.** Текст главы получает две РАЗНЫЕ модели (flash → pro) плюс гейты; банк, который потом связывает каждую главу, — один проход `deepseek-v4-flash`. Отложенный судья-с-декоем (D39.51 п.4, строка 5 бэклога) ждал замера холодного старта — замер есть. +2. **Ветку «транслитерировать или переводить» решает НЕПРОВЕРЕННОЕ поле `type`** — третья колонка от черновой модели. `元石` («первородный камень», предмет) пришёл типом `name` ⇒ получил и ветку транскрипции, и бонус соответствия Палладию ⇒ «юаньши». Промпт роли и типизация формулы (`terminologist.go:165-171` — конформность только для name/place) написаны верно; обе стоят на непроверенном типе. Это механизм жалобы владельца «слишком китаизированно». +3. **Консистентность серий не обеспечена ничем:** `甲等 → класс Цзя`, но `乙等/丙等/丁等 → ранг И/Бин/Дин` — одна серия, два родовых слова, потому что термы попали в разные батчи роли, а роль не видит серии. +4. **Проводной формат каналов пересказан прозой в промптах каждой пары**, парсер живёт в Go, связи нет. Дефект `` — прямое следствие; линты закрывают класс, но не устраняют дублирование контракта. Предложение: формат — переменная шаблона от движка (`{{banknote_format}}`), пар-пакет несёт только язык и обёртку. Цена: `RenderVars` + строка в каждом пар-промпте, `PromptSHA256` двигается один раз (сегодня не перекупает никого — гейта нет ни в одном шиппинг-конфиге). +5. **Квадратичность правила src** (строка × длина книги при промахе чанк-пути): на 10 главах не видна, на 2284 разделах — риск; рядом со строкой 37 (квадратичность `AttachKWIC`). +6. Дефект `` был латентен и во втором промпте (`terminologist.md`) — не стрелял только потому, что рядом стоял пример с настоящей табуляцией (0 из 75 строк роли). Несущее правило — «формат надо ПОКАЗАТЬ», оно и запинено. + +## §6. Предложения (в порядке, в котором я бы их делал) + +1. **Валидация типа** — дешевле и важнее второго мнения: сверка с классификатором майнера там, где терм нашли оба канала; детерминированный экран «`name`, чей dst — многословная строчная фраза»; требование к роли обосновывать тип. Центы или ноль, а управляет главной развилкой качества банка. +2. **Второе мнение по архитектуре глав:** другая модель ревьюит сведённый банк, её несогласия — ОТДЕЛЬНОЙ колонкой в подписной таблице, а не автоматическим решением. Это отложенный судья в роли рецензента; ограничение D39.46 («слепой судья годен как экран, не как ранжировщик») не бьёт, потому что решает владелец. Цена: порядок первого прохода ($0.013) на дешёвой модели. +3. **Показать владельцу ось решения** для термов с расхождением (75 из 150): два полюса — транскрипция против смысла — рядом. +4. **Эксперимент «язык промптов»** (гипотеза владельца 31.07, строка 30в бэклога): арм A ru / арм B en при НЕИЗМЕННЫХ инъекциях (иначе меряются два фактора), метрика — детерминированные счётчики следования формату (`parse_fail`, `off_language`, `bad_lines`, битый разделитель), судья не нужен. Двусторонний риск: английский промпт на zh→ru может тянуть выход в английский — прибор есть (`off_language`, базлайн 0 из этого прогона). Зона полигона, ≈$0.02–0.05. +5. **Рефакторинг формата в шаблон** (§5 п.4) — вместе с любой следующей правкой промптов, одной оплатой. + +## §7. Что прогон НЕ ответил + +- **Полная цена холодного старта до конца** — edit-волна не гонялась (сессия закрыта решением владельца). Измерены все стадии ДО редактуры. +- **Авто-режим флага** — проект Б не запускался. +- **Оси голоса A–D** — `gates.voice` был включён, но реестр флаггера пуст ПО ПОСТРОЕНИЮ: он собирается только из `voices:`/`addresses:` сида (`voicerun.go:25-27`, `memvoice.go:343-360`), а владелец не подписывал профили (черновик подготовлен, `coldrun-a/SIGN-PACKAGE.md` §3). Отчитывается как **«не измерено»**, не как «чисто». Строка 13б (`speech-cue.txt`) остаётся открытой: `SourceReplies` = 0 по той же причине. +- **Пере-замер P1 consistency (21) и весов §C2-3 (22)** — сырьё есть (4 сэмпла, разброс по 150 термам), метод — полигонский, не исполнялся. +- **Точечная ре-редактура по ключу после поздней подписи** — не проверялась (подписи не было). + +## §8. Состояние + +Сессия **не коммитила**. Дерево: 11 файлов бэкенда изменено, 3 новых (`config/prompt_lint_test.go`, `pipeline/allowshort_wire_test.go`, этот отчёт). В дереве есть и правки параллельной полигон-сессии (`docs/archive/reports/TOS_RECHECK_2026-07-31.md`, `docs/experiments/00-provider-quirks.md`) — **не мои, не тронуты**. Временные измерительные харнессы удалены. + +Артефакты прогона (вне git): `~/books/gu-zhenren/coldrun-a/` — БД, 4 лога волн, подписная карта (142 терма), полная таблица банка (150), плоский свод `BANK-FULL.tsv`, подписной пакет владельцу `SIGN-PACKAGE.md`. Стендовые БД и снапшоты не тронуты (33 хеша сверены). + +## §9. Критик полноты (обязательная секция) + +- **Recall 0.918 льстит по построению:** эталон — сид, собранный с знанием этой книги и частично из прошлых майнинг-прогонов; независимого золотого набора нет. Число говорит «холодный банк находит то, что владелец уже подписал», а не «находит всё, что нужно». +- **Четыре сэмпла — не четыре независимых замера:** три из них на одном и том же чанк-разбиении одной книги одной моделью; разброс между сэмплами смешан с разбросом между промптами. +- **«Утечка алфавита = 0» — свойство ЭТОГО прогона**, а не доказательство отсутствия класса: полигон ловил её на другой сборке и другой выборке. +- **Эхо 2/20 — событие редкое**, и разница с прошлым прогоном (0/20) статистически неотличима от шума; причинную связь с правкой промпта я НЕ утверждаю. +- **Оценка «слишком китаизированно» принадлежит владельцу**, я её не мерил; §5 п.2 предъявляет механизм, а не подтверждение оценки. +- **`parse_fail` на 3 чанках** предъявлен строками, но я не проверял, воспроизводится ли класс «таблица целиком на целевом языке» на другой книге/паре. +- Не проверено адверсариально: моё же утверждение «чанк-путь есть строгое подмножество книжного» держится тестом на двух чанках, а не доказательством на реальном разбиении. diff --git a/docs/archive/reports/TOS_RECHECK_2026-07-31.md b/docs/archive/reports/TOS_RECHECK_2026-07-31.md index 5a8f116a..919af6e3 100644 --- a/docs/archive/reports/TOS_RECHECK_2026-07-31.md +++ b/docs/archive/reports/TOS_RECHECK_2026-07-31.md @@ -127,6 +127,8 @@ ### 6.1 Как добывалось - Прямой `curl` с браузерными заголовками; текст — собственным стриппером; **каждая цитата подтверждена счётом вхождений `= 1`** (`verify.py`, матрица `matrix.json`: **34 цитаты, 0 неподтверждённых**). +- ✅ **Усиление метода, добытое самопроверкой (рекомендую сделать формой ре-чека впредь): «нет дельты» доказывать НЕ объявленной датой ревизии, а ПОСТРОЧНЫМ ДИФОМ тела против снимка на дату прошлого чека.** Объявленная дата — заявление вендора и может быть несвежей (урок календаря: сноска DeepSeek о катовере осталась в будущем времени уже после катовера). Исполнено здесь для двух документов фокуса: **тело PUP — снимок Wayback 26.07 против живой копии 31.07: ИДЕНТИЧНО построчно (27 строк против 27)**; **тело Gemini Additional ToS — снимок 29.07 против живой 31.07: идентично**, разошлась только навигация сайта. То есть «не менялось» здесь — измерение, а не доверие к шапке документа. +- **Контроль против собственного инструмента:** все несущие счётчики пере-мерены по СЫРОМУ HTML мимо стриппера — `violen*` = 0 и `sexual*` = 0 в обеих ревизиях головного ToS, `suitable for everyone` = 0 в ревизии 2024 и = 1 в ревизии 2026, `violen*` = 0 в обоих снимках xAI AUP. Стриппер ничего не прятал. - ⚠ **Поправка к методу пакета-5:** проверка «`grep -c -F` по стриппнутому тексту» **даёт ложные нули**. Поймано на двух документах: DeepSeek ToU верстает жёсткими переносами (`
` рвал предложение), а DeepSeek Open Platform ToS держит кавычки в отдельных тегах, из-за чего стриппер выдаёт `" DeepSeek Terms of Use "` с пробелами. Три верные цитаты сначала показали `0`. **Лечение, применённое здесь: сверять по копии с УДАЛЁННЫМИ пробелами с обеих сторон** — нечувствительно к вёрстке, последовательность символов не трогает. Без этой поправки ре-чек показал бы фантомную дельту DeepSeek. - **`ai.google.dev` больше не требует Wayback.** Прежняя запись «редиректит в OAuth, curl умирает на 50 редиректах» верна по симптому; причина — cookie `signin=autosignin` в петле. **Лечится cookie-jar (`-c`/`-b`) + `?hl=en` → HTTP 200 живьём.** Живая копия сверена построчно со снимком Wayback 29.07: тело договора идентично, разошлась только навигация сайта. - **Головной Google ToS:** взяты обе страновые версии (Ireland и United States) и архивная ревизия `20240522`; дельта построена **своим** дифом, редлайн Google использован только как перекрёстная проверка (сошлись). diff --git a/docs/experiments/00-provider-quirks.md b/docs/experiments/00-provider-quirks.md index e18dcbd2..8dabd93e 100644 --- a/docs/experiments/00-provider-quirks.md +++ b/docs/experiments/00-provider-quirks.md @@ -43,6 +43,7 @@ **«reasoning-off + плотный CJK-вход = зона эха».** Это свойство **класса** reasoning-моделей, а НЕ квирк отдельного вендора: воспроизведено на deepseek (non-thinking режим, `00`-эхо-бюллетень выше) и на **обоих** слагах grok при `reasoning_effort:"none"` (exp10/D19.1: 4/10 verbatim-эхо + 1 дегенеративный луп на zh-фрагментах насилия, `reasoning_tokens=0`; контроль тех же фрагментов при reasoning-ON — 0/10 эха, `reasoning_tokens>0`). Механика одна: без цепочки рассуждений модель на плотной CJK-прозе возвращает исходник вместо перевода — **валидный HTTP 200 с неправильным содержимым** (не ошибка API, не `content_filter`; ретраи не спасают, эхо стохастично per-fragment). Следствия: - **Для роли ПЕРЕВОДЧИКА на плотном CJK reasoning-off непригоден в принципе** (не только у одного вендора) — канал B переводит reasoning-ON (grok) либо не-reasoning-моделью без эхо-мины (Mistral: проба zh→ru чисто, `cjk_share=0`). ⚠ **Register-оговорка (exp11/D22, дописано оркестратором):** reasoning-ON снимает эхо на СОВРЕМЕННОЙ прозе (совр. zh-вебновелла 0/10, совр. ja 1/6, en 0/6), но НЕ на архаичной плотно-ханьской (金瓶梅, минский байхуа: grok-ON эхо 4/6 при `reasoning_tokens` 349–847; ON vs OFF там неразличимы) — эскалацию на grok-ON не считать лекарством от эха на архаичных zh-текстах; Mistral на той же архаике чист 6/6. - **Downstream echo-гейт (`untranslated_echo`, `cjk_share>0.15`) обязателен НАВСЕГДА** и промпт-митигациями НЕ заменяется — это последняя линия против тихого провала. +- ⚠ **thinking-ON НЕ исключает эхо даже на современной прозе (живой прогон 31.07, D39.58):** 2/20 черновиков `deepseek-v4-flash` thinking-ON вернулись чистым исходником (`han_share=0.999`, 蛊真人 гл.5/9, современный zh). Гейт поймал оба, эскалация на dspro вылечила ($0.0147). thinking-ON снижает ЧАСТОТУ эха, но не до нуля; «echo 0» пере-прогона 23.07 (D39.20) был свойством выборки. Практика: закладывать в смету черновой волны ~10% на эскалации. *(Внесено оркестратором по D39.58.)* - ⚠ **Инверсия языкового констрейнта (research/15 P4):** языковая строка в `system` у reasoning-off модели может **УСИЛИВАТЬ** эхо, а не гасить его — на grok `reasoning_effort:none` одна формулировка констрейнта подняла эхо 3/10→9/10. Проверена **одна** формулировка на одном слаге; чувствительность к формулировке/модели неизвестна. → **Запрет (D21.6): не вносить языковые констрейнты в промпты reasoning-off путей без per-model замера.** (Латентное: `translator.md:10` уже несёт языковую строку, но едет только на thinking-ON DeepSeek — вне зоны инверсии; при заводке Mistral-канала B прогнать P4-реплику на боевом промпте.) ## Параметры сэмплинга @@ -116,6 +117,7 @@ - **Cloudflare-403 на `x.ai` и `openai.com`** (воспроизводится и в WebFetch): обход — Wayback `https://web.archive.org/web/id_/` (суффикс `id_` = оригинальные байты; добавлять `--compressed`, иначе приходит gzip-мусор). **Timestamp снимка ≠ дата ревизии документа** — не путать. - ⚠ **`r.jina.ai` как ВТОРОЙ путь МЁРТВ (31.07.2026): 403** и на `x.ai`, и на `ai.google.dev`. Правило пакета-5 «цитата засчитана только при совпадении Wayback и jina» в прежней форме неисполнимо. **Замена, применённая 31.07:** два НЕЗАВИСИМЫХ захвата Wayback разных дат + сверка тел построчно (для xAI: снимки 25.07 и 28.07 побайтно идентичны). Честная граница такой замены — оба снимка в ПРОШЛОМ, живого подтверждения на дату чека нет; писать это явно. Прямой egress (`--noproxy '*'`) от Cloudflare-403 тоже не спасает — проверено. - ⚠ **`ai.google.dev` БОЛЬШЕ НЕ ТРЕБУЕТ Wayback (31.07.2026).** Прежняя запись «редиректит в OAuth, curl умирает на 50 редиректах» верна по симптому; причина — петля на cookie `signin=autosignin`. **Лечится cookie-jar + явный язык: `curl -L --compressed -c jar -b jar 'https://ai.google.dev/gemini-api/terms?hl=en'` → HTTP 200 живьём.** Живая копия сверена со снимком Wayback: тело договора построчно идентично, расходится только навигация сайта. +- ✅ **ФОРМА РЕ-ЧЕКА ВПРЕДЬ (усиление 31.07): «дельты нет» доказывать не объявленной датой ревизии, а ПОСТРОЧНЫМ ДИФОМ тела документа против снимка Wayback на дату ПРОШЛОГО чека.** Объявленная дата — заявление вендора, оно бывает несвежим (тот же класс, что сноска DeepSeek о катовере, оставшаяся в будущем времени уже после катовера). Так закрыты 31.07 оба документа фокуса: тело PUP (снимок 26.07 vs живая 31.07) — идентично построчно, 27 строк против 27; тело Gemini Additional ToS (снимок 29.07 vs живая) — идентично, расходится только навигация сайта. - **Головной Google ToS отдаётся по СТРАНОВЫМ версиям** (`?hl=en-US&gl=us` = Google LLC, дефолт с нашего egress = Google Ireland Limited) — версии текстуально разные, дату ревизии смотреть на нужной. Архив ревизий: `policies.google.com/terms/archive`, редлайн между ревизиями — `…/archive/<от>-<до>` (``/`` в разметке). **Редлайн вендора использовать как перекрёстную проверку, а дельту строить своим дифом архивной и живой копий.** - ⚠ **`mistral.ai/terms/usage-policy` отдаёт HTTP 404** (и снимков в Wayback нет) — рабочий хост **`legal.mistral.ai/terms/usage-policy`**. Наш egress — прокси, так что 404 может быть edge-специфичным; при пере-проверке начинать с `legal.` . - ⚠ **WebFetch для цитат НЕ годится** — режет цитату ~125 символами и переходит на пересказ. Только `curl` + `grep -F`.