Land the readability infra-pack (D38): cosmetic-class strip-and-export for flagged chunks, a CJK-leak sanitizer class, and an opt-in post-reflow number/omission regression guard

This commit is contained in:
Claude (backend session) 2026-07-12 22:39:03 +03:00
parent b14ba6f20c
commit d5beefc8f4
19 changed files with 929 additions and 139 deletions

View file

@ -26,9 +26,16 @@ import (
func renderTranslate(w io.Writer, res *pipeline.BookResult, ledger func() (committed, reserved float64, err error)) error {
for _, ch := range res.Chunks {
fmt.Fprintf(w, "=== ГЛАВА %d ЧАНК %d — %s%s ===\n", ch.Chapter, ch.ChunkIdx, ch.Disposition, flagSuffix(ch.FlagReason))
if ch.Disposition == pipeline.DispOK {
switch {
case ch.Disposition == pipeline.DispOK:
fmt.Fprintln(w, ch.FinalText)
} else {
case ch.FinalText != "":
// Cosmetic sanitizer strip (D35.4a): the leak was removed and the remainder exported,
// but the chunk stays flagged for a human to verify the auto-clean — not lost to an
// empty placeholder (ch5/ch20 chapter openers used to drop whole for a leading «###»).
fmt.Fprintf(w, "[ФЛАГ %s — утечка вычищена, экспортирован очищенным, проверь] ↓\n", ch.FlagReason)
fmt.Fprintln(w, ch.FinalText)
default:
fmt.Fprintf(w, "[ФЛАГ %s] чанк не переведён — черновик/редактура непригодны, помечен для человека\n", ch.FlagReason)
}
for _, st := range ch.Stages {
@ -159,7 +166,7 @@ func renderReport(w io.Writer,
fmt.Fprintf(w, "%-4d %-6d %6d %s\n", rs.Chapter, rs.ChunkIdx, rs.NPostcheckMiss, rs.PostcheckDetail)
}
// Cheap style/number flaggers (observability, not gates): total + per-chunk detail.
fmt.Fprintf(w, "\n=== СТИЛЬ-ГЕЙТЫ (наблюдаемость: тире-диалоги, ё, транслит-междометия, разряды 万/億) — всего %d ===\n", style)
fmt.Fprintf(w, "\n=== СТИЛЬ-ГЕЙТЫ (наблюдаемость: тире-диалоги, ё, транслит-междометия, разряды 万/億, reflow-регрессия) — всего %d ===\n", style)
stylePrinted := false
for _, rs := range states {
if rs.NStyleFlags == 0 {

View file

@ -86,9 +86,22 @@ type Stage struct {
// Gates is the QA-gate config skeleton (пороги полигона; исполнение — Фаза 1).
type Gates struct {
Coverage CoverageGate `yaml:"coverage"`
Glossary GlossaryGate `yaml:"glossary"`
Sanitizer SanitizerGate `yaml:"sanitizer"`
Coverage CoverageGate `yaml:"coverage"`
Glossary GlossaryGate `yaml:"glossary"`
Sanitizer SanitizerGate `yaml:"sanitizer"`
RegressionGuard RegressionGuardGate `yaml:"regression_guard"`
}
// RegressionGuardGate controls the post-reflow regression guard (D38 infra-pack,
// research/18 §C#5): two deterministic OBSERVABILITY flaggers over the draft→final transform —
// a length collapse and a numeric drift — that surface a reflow that dropped content or drifted a
// number (四成四=44%→«четыре десятых»). Opt-in (default false), and by design NEVER a disposition
// change: the reflow editor legitimately restructures/merges, so a hard skip would false-flag a
// good edit — a hit is recorded in the cheap-gate observability channel (retrieval-state
// n_style_flags) and surfaced in the report, never dropping the chunk. Its thresholds are code
// consts (versioned with the cheap gates), so the gate carries only an on/off switch.
type RegressionGuardGate struct {
Enabled bool `yaml:"enabled"`
}
// SanitizerGate controls the output-sanitizer (D30.3): a deterministic verdict-axis

View file

@ -22,10 +22,16 @@ type StageResult struct {
LatencyMS int
FinishReason string
Text string // the usable output (only on an ok disposition)
Disposition Disposition
FlagReason FlagReason // "" when ok
Detail string
Attempts int
// RecoveredText is the cosmetic-stripped export text (D35.4a): non-empty ONLY for a
// FlagSanitizerStripped final stage, where the sanitizer removed a leading markdown header /
// CJK-leak and committed the cleaned remainder. It carries the export forward on BOTH the
// fresh path (runStage) and resume (resumeFromChunkStatus reads the derived checkpoint), so
// translateChunk assembles the identical FinalText either way. "" for every other disposition.
RecoveredText string
Disposition Disposition
FlagReason FlagReason // "" when ok
Detail string
Attempts int
// Escalated — a single-hop fallback draft was tried this stage (D12); when the
// fallback passed the re-gate, Model above is the fallback (it answered).
Escalated bool
@ -34,10 +40,13 @@ type StageResult struct {
// ChunkOutcome is one chunk's result across the stage list.
type ChunkOutcome struct {
Chapter int
ChunkIdx int
Stages []StageResult
FinalText string // the last stage's output; "" when the chunk is flagged
Chapter int
ChunkIdx int
Stages []StageResult
// FinalText is the exported text: the last stage's output on ok; the cosmetic-stripped
// remainder on a FlagSanitizerStripped flag (D35.4a — the chunk is exported flagged, not lost
// to an empty placeholder); "" on any other flag (the contaminated output never exports).
FinalText string
Disposition Disposition // ok | flagged (a chunk has no "skipped" — that is a per-later-stage state)
FlagReason FlagReason // the flagging stage's reason ("" when ok)
CostUSD float64 // THIS run's spend on this chunk

View file

@ -46,6 +46,10 @@ const cheapGateVersion = "cheapgate-v2"
type cheapGateConfig struct {
yoPolicy string // "auto" (inconsistency only) | "all-yo" | "all-e"
allowlist map[string]bool // lower-cased surfaces exempt from the interjection blocklist
// regressionEnabled turns on the post-reflow regression guard (D38, regressionguard.go): an
// OPT-IN observability flagger (draft→final length collapse + number drift) folded into this
// result. Off → the two regression fields stay 0 and the output is byte-identical to before.
regressionEnabled bool
}
// cheapGateResult is the per-chunk outcome: a count per flagger plus human-readable detail lines
@ -55,15 +59,22 @@ type cheapGateResult struct {
YoInconsistent int `json:"yo,omitempty"`
TranslitInterj int `json:"translit_interj,omitempty"`
NumberMagnitude int `json:"number_magnitude,omitempty"`
Detail []string `json:"detail,omitempty"`
// LengthCollapse / NumberDrift are the opt-in post-reflow regression guard (D38,
// regressionguard.go), folded into this observability result. They stay 0 unless
// cfg.regressionEnabled, so a book that does not enable the guard serialises identically.
LengthCollapse int `json:"length_collapse,omitempty"`
NumberDrift int `json:"number_drift,omitempty"`
Detail []string `json:"detail,omitempty"`
}
func (c cheapGateResult) total() int {
return c.DialogueDash + c.YoInconsistent + c.TranslitInterj + c.NumberMagnitude
return c.DialogueDash + c.YoInconsistent + c.TranslitInterj + c.NumberMagnitude + c.LengthCollapse + c.NumberDrift
}
// runCheapGates runs all four flaggers over one chunk's source and FINAL translated text.
func runCheapGates(source, final string, cfg cheapGateConfig) cheapGateResult {
// runCheapGates runs the four always-on style flaggers over one chunk's source and FINAL text, plus
// (opt-in) the draft→final regression guard. `draft` is the first-stage translator output (== final
// when there is no distinct reflow stage, so the guard then trivially never fires).
func runCheapGates(source, draft, final string, cfg cheapGateConfig) cheapGateResult {
var r cheapGateResult
n, det := lintDialogueDash(final)
r.DialogueDash, r.Detail = n, append(r.Detail, det...)
@ -76,6 +87,12 @@ func runCheapGates(source, final string, cfg cheapGateConfig) cheapGateResult {
n, det = lintNumberMagnitude(source, final)
r.NumberMagnitude = n
r.Detail = append(r.Detail, det...)
if cfg.regressionEnabled {
rg := runRegressionGuard(draft, final)
r.LengthCollapse = rg.LengthCollapse
r.NumberDrift = rg.NumberDrift
r.Detail = append(r.Detail, rg.Detail...)
}
return r
}

View file

@ -168,7 +168,7 @@ func TestRunCheapGatesCombined(t *testing.T) {
src := "他有三万石粮食。"
final := "- Ара-ара, — у Пётр было три миллиона мешков. Потом Петр ушёл."
cfg := cheapGateConfig{yoPolicy: "auto", allowlist: map[string]bool{}}
r := runCheapGates(src, final, cfg)
r := runCheapGates(src, final, final, cfg)
if r.DialogueDash == 0 {
t.Error("expected a dialogue-dash flag (hyphen-led line)")
}

View file

@ -6,6 +6,7 @@ import (
"fmt"
"maps"
"slices"
"strings"
"textmachine/backend/internal/store"
)
@ -39,6 +40,17 @@ func (r *Runner) classifyOutput(role, source, output, finish string, isFinal boo
// its rule version folds into the snapshot only when enabled (sanitizerSnapshot).
if isFinal && r.Pipeline.Gates.Sanitizer.Enabled {
if san := sanitizeOutput(output); san.total() > 0 {
// Cosmetic-only leak (leading markdown header and/or CJK-leak) → strip it and export
// the remainder flagged, not drop the whole chunk (D35.4a). The guard is load-bearing:
// we tag sanitizer_stripped ONLY when the strip yields a NON-EMPTY output that is now
// clean, so runStage/resume can trust that the derived export text is usable; anything
// else falls through to the substantive skip. Both are pure over `output`, so a resumed
// checkpoint re-derives the identical verdict.
if san.cosmeticOnly() {
if stripped := stripCosmetic(output); strings.TrimSpace(stripped) != "" && sanitizeOutput(stripped).total() == 0 {
return classification{FlagSanitizerStripped, san.summary()}
}
}
return classification{FlagSanitizerDefect, san.summary()}
}
}
@ -73,6 +85,14 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, st
prev := ""
flagged := false
var flagReason FlagReason
// recovered carries a cosmetic sanitizer strip's cleaned export forward from the flagging
// stage (D35.4a): non-empty only when the FINAL stage flagged FlagSanitizerStripped, "" for a
// dropped substantive flag. Captured from the FIRST flagging stage (the sanitizer flags only
// the final stage, so an upstream substantive flag leaves it "" and the chunk exports empty).
recovered := ""
// draftText is the FIRST (translator) stage's output — the pre-reflow baseline for the opt-in
// regression guard's draft→final comparison (== final in a single-stage pipeline).
draftText := ""
// Hot path: select the glossary records for this chunk ONCE (deterministic, $0), and
// serialize the translator injection block. Recomputed on every run (resumed chunks
@ -127,9 +147,13 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, st
if sr.Disposition == DispFlagged {
flagged = true
flagReason = sr.FlagReason
recovered = sr.RecoveredText
continue
}
prev = sr.Text
if stageIdx == 0 {
draftText = sr.Text // the translator draft, before any reflow (regression-guard baseline)
}
}
// Post-check the FINAL, exported output (E1): the reader sees the last stage's text
@ -161,7 +185,7 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, st
// (the 万/億 magnitude gate compares source↔output).
var cheap cheapGateResult
if !flagged && prev != "" {
cheap = runCheapGates(ch.Text, prev, r.cheapGateConfig())
cheap = runCheapGates(ch.Text, draftText, prev, r.cheapGateConfig())
if cheap.total() > 0 {
r.Log.InfoContext(ctx, "cheap style gates flagged the chunk (observability, not a gate)",
"chapter", ch.Chapter, "chunk", ch.ChunkIdx, "style_flags", cheap.total(),
@ -183,7 +207,10 @@ func (r *Runner) translateChunk(ctx context.Context, snapID string, ch Chunk, st
if flagged {
out.Disposition = DispFlagged
out.FlagReason = flagReason
out.FinalText = "" // garbage/refusal/glossary-miss never propagates to the next chunk or export
// A cosmetic sanitizer strip exports its cleaned remainder (recovered != ""); every other
// flag (garbage/refusal/glossary-miss/substantive-sanitizer) exports "" — the contaminated
// output never reaches TM/export (D2 / D35.4a).
out.FinalText = recovered
} else {
out.FinalText = prev
}
@ -247,5 +274,9 @@ func (r *Runner) cheapGateConfig() cheapGateConfig {
for _, s := range r.Book.StyleAllowlist {
allow[s] = true
}
return cheapGateConfig{yoPolicy: r.Book.YoPolicy, allowlist: allow}
return cheapGateConfig{
yoPolicy: r.Book.YoPolicy,
allowlist: allow,
regressionEnabled: r.Pipeline.Gates.RegressionGuard.Enabled,
}
}

View file

@ -82,14 +82,24 @@ const (
// escalatable in v1 (the L3 targeted re-ask is a Phase-2 remedy, research/14 §9).
FlagGlossaryMiss FlagReason = "glossary_miss"
// FlagSanitizerDefect is the output-sanitizer verdict (D30.3, sanitizer.go): the stage
// output carries an "instant unreadability" defect no other gate catches — a leaked
// service preamble, a trailing note/edit block, a markdown ### header, a Latin-script
// insertion, or a broken word form. Emitted ONLY when the opt-in Gates.Sanitizer is
// enabled; the contaminated output is flagged (D2 flag+skip) so it never commits to
// TM/export. NOT retryable (a same-model retry re-produces the artifact) and NOT
// escalatable in v1 (the editor is pinned; a redrive surfaces the defect to a human).
// FlagSanitizerDefect is the output-sanitizer verdict for a SUBSTANTIVE defect (D30.3,
// sanitizer.go): the stage output carries an "instant unreadability" defect with no reliable
// removable boundary — a leaked service preamble, a trailing note/edit block, a Latin-script
// insertion, or a broken word form. Emitted ONLY when the opt-in Gates.Sanitizer is enabled;
// the contaminated output is flagged AND skipped (D2 flag+skip) so it never commits to
// TM/export (FinalText stays ""). NOT retryable (a same-model retry re-produces the artifact)
// and NOT escalatable in v1 (the editor is pinned; a redrive surfaces the defect to a human).
FlagSanitizerDefect FlagReason = "sanitizer_defect"
// FlagSanitizerStripped is the output-sanitizer verdict for a COSMETIC-ONLY defect that was
// STRIPPED and the remainder EXPORTED (D38 infra-pack, D35.4a): a leading markdown «### Глава»
// header and/or a stray CJK-leak run, removed deterministically by stripCosmetic. Unlike
// FlagSanitizerDefect the chunk is NOT lost — the cleaned text is committed as the export
// (final_hash points at a derived sanitized checkpoint, so the standard
// final_hash→checkpoint.response_text export contract yields the cleaned text) — but it STAYS
// flagged so a human verifies the auto-clean ("не терять чанк ... флаг для человека"). NOT
// retryable / NOT escalatable (deterministic; a redrive would re-produce the same leak).
FlagSanitizerStripped FlagReason = "sanitizer_stripped"
)
// classifierVersion versions the INTRINSIC classify() verdict logic — the refusal

View file

@ -41,8 +41,15 @@ import (
// • UNION: ch5 fires an OBLIQUE-ONLY decl term (老人→старик) whose FINAL text carries the
// NOMINATIVE «старик» → postcheck_miss=0 only because the base-dstdecl union checks the
// base (revert the union → this chunk false-flags a miss);
// • SANITIZER: ch6 draft leaks a service preamble → the output-sanitizer gate (ON in the
// fixture pipeline) flags it FlagSanitizerDefect and the edit is skipped.
// • SANITIZER (SUBSTANTIVE): ch6 edit leaks a service preamble → the output-sanitizer gate (ON
// in the fixture pipeline) flags it FlagSanitizerDefect and the edit is DROPPED (final_text "").
// D38 infra-pack cells (pin the cosmetic strip-and-export path, D35.4a — final_hash points at a
// derived $0 sanitized checkpoint so the export contract yields the cleaned text; resume re-serves
// it identically at $0):
// • SANITIZER (COSMETIC markdown): ch7 edit leaks a leading «### Глава 7» → flagged
// sanitizer_stripped, final_text = «Глава 7\n\n…» (the «### » stripped), a derived checkpoint holds it;
// • SANITIZER (COSMETIC CJK): ch8 edit leaks a sparse «特产» → flagged sanitizer_stripped, final_text
// = the run removed and the seam tidied.
// A cell with AMBIGUOUS>0 (an auto/draft candidate forcing a post-check) remains an OPTIONAL
// future extension, deliberately NOT added here.
//
@ -57,7 +64,9 @@ const (
goldenEchoMarker = "響動計画"
goldenRefusalMarker = "拒絶計画"
goldenUnionMarker = "老人の秘密" // ch5 — oblique-only decl union case
goldenSanitizerMarker = "序文漏洩" // ch6 — sanitizer preamble-leak case
goldenSanitizerMarker = "序文漏洩" // ch6 — sanitizer preamble-leak case (SUBSTANTIVE → dropped)
goldenMarkdownMarker = "見出漏洩" // ch7 — cosmetic markdown «### Глава» leak (STRIPPED + exported)
goldenCJKMarker = "漢字漏洩" // ch8 — cosmetic CJK-leak «特产» (STRIPPED + exported)
)
// goldenRespond is the deterministic mock provider brain. The response text is a pure
@ -85,6 +94,12 @@ func goldenRespond(body string) (text, finish string) {
case strings.Contains(body, goldenUnionMarker) && !isEditBody(body):
// ch5 draft carries a keying token («СТАРИК-СЦЕНА») the edit branch below keys on.
return "ЧЕРНОВИК ПЕРЕВОДА " + tag + ". СТАРИК-СЦЕНА Судзуки увидел старика.", "stop"
case strings.Contains(body, goldenMarkdownMarker) && !isEditBody(body):
// ch7 draft carries a keying token («МАРКДАУН-СЦЕНА») the cosmetic edit branch keys on.
return "ЧЕРНОВИК ПЕРЕВОДА " + tag + ". МАРКДАУН-СЦЕНА Судзуки открыл седьмую дверь.", "stop"
case strings.Contains(body, goldenCJKMarker) && !isEditBody(body):
// ch8 draft carries a keying token («ИЕРОГЛИФ-СЦЕНА») the cosmetic edit branch keys on.
return "ЧЕРНОВИК ПЕРЕВОДА " + tag + ". ИЕРОГЛИФ-СЦЕНА Судзуки нашёл камень.", "stop"
case strings.Contains(body, goldenEchoMarker) && !isEditBody(body):
if req.Model == "fake-fallback" {
// The escalation hop returns a clean translation → the re-gate passes it.
@ -100,6 +115,16 @@ func goldenRespond(body string) (text, finish string) {
// ch5 edit — the FINAL text carries the NOMINATIVE «старик» (not an oblique decl
// form of 老人→старик), exercising the base-dstdecl union → postcheck_miss=0.
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД " + tag + ". Судзуки увидел, как в тени ждал старик.", "stop"
case isEditBody(body) && strings.Contains(body, "МАРКДАУН-СЦЕНА"):
// ch7 edit leaks a leading markdown «### Глава» header — a COSMETIC class: the sanitizer
// STRIPS it and exports the remainder flagged sanitizer_stripped (D35.4a). This PINS the
// strip-and-export path: final_text = «Глава 7\n\n…» (no «### »), disposition=flagged.
return "### Глава 7\n\nСудзуки открыл седьмую дверь книгохранилища.", "stop"
case isEditBody(body) && strings.Contains(body, "ИЕРОГЛИФ-СЦЕНА"):
// ch8 edit leaks a sparse CJK run «特产» in otherwise-Russian prose (below the 15% echo
// threshold classify() catches) — a COSMETIC class: stripped + exported flagged. PINS the
// CJK-leak strip: «Судзуки нашёл 特产 древний камень.» → «Судзуки нашёл древний камень.»
return "Судзуки нашёл 特产 древний камень на каменном алтаре.", "stop"
case isEditBody(body):
return "ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД " + tag + ". Судзуки шёл по коридорам Академии магии.", "stop"
default:
@ -171,7 +196,7 @@ func captureGolden(t *testing.T, label string, r *Runner, res *BookResult, wireB
w(" stage=%s role=%s model=%s resume=%t disp=%s flag=%q attempts=%d escalated=%t esc_model=%q finish=%q cum_usd=%s detail=%q",
st.Stage, st.Role, st.Model, st.FromResume, st.Disposition, st.FlagReason,
st.Attempts, st.Escalated, st.EscalationModel, st.FinishReason, fl(st.CumCostUSD), st.Detail)
w(" stage_text=%q", st.Text)
w(" stage_text=%q recovered=%q", st.Text, st.RecoveredText)
}
}

View file

@ -0,0 +1,159 @@
package pipeline
import (
"fmt"
"sort"
"unicode"
)
// regressionguard.go: the post-reflow regression guard (D38 infra-pack, research/18 §C#5) — two
// deterministic OBSERVABILITY flaggers over the DRAFT→FINAL transform, opt-in like a QA gate
// (Gates.RegressionGuard) but NEVER a disposition change. The reflow editor legitimately
// restructures and merges sentences, so a HARD skip would false-flag a correct edit; instead a hit
// is recorded in the cheap-gate observability channel (retrieval-state n_style_flags) and surfaced
// in the report for a human. Both flaggers are tuned PRECISION over recall.
//
// (a) length collapse — the final is drastically SHORTER than the draft (>regressionMaxShrinkPct
// of non-space chars), catching a reflow that dropped whole sentences/paragraphs (draft
// 5000 → final 600). Reflow SUMMARISES structure into paragraphs; it must not delete content.
// (b) number drift — a MULTI-DIGIT arabic number in the draft is ABSENT from the final, or a
// new one APPEARS: 四成四=44%→«четыре десятых» (the 44 vanished), or a hallucinated figure.
// Single digits are EXCLUDED (they are routinely spelled out — «5»→«пять» — a legit reflow),
// and grouped triples are stitched (10 000 ≡ 10000), so the signal is high precision.
//
// HONEST recall gap: a number rendered as WORDS on BOTH sides (черновик «сорок четыре» → финал
// «четыре десятых») is invisible here — offline word-number alignment is a Ф2 concern. And a
// legit reflow that converts a multi-digit figure to words («44%»→«сорок четыре процента») will
// show as a disappeared number: a FALSE observability flag, tolerable ONLY because this never
// drops the chunk (a human glance dismisses it). Precision over recall, observability over gating.
const (
// regressionMaxShrinkPct: a final shorter than the draft by MORE than this percent of non-space
// characters is a collapse signal. 40% — a reflow that keeps content while merging lines shrinks
// only slightly (Russian is ~fertility-neutral draft→final); losing ~half the characters is a
// strong omission signal, not a merge. Tuned precision-over-recall; a code const (a change is a
// loud cheapGateVersion bump), not config, so the gate carries only an on/off switch.
regressionMaxShrinkPct = 40
// regressionMinChars: below this the draft is too short for a percentage ratio to be meaningful
// (a 3-word draft legitimately halving is noise), so the length flagger stays silent.
regressionMinChars = 200
)
// regressionGuardResult is the guard's per-chunk outcome (folded into the cheap-gate observability
// result). LengthCollapse/NumberDrift are 0/1 counts; Detail carries the human-readable lines.
type regressionGuardResult struct {
LengthCollapse int
NumberDrift int
Detail []string
}
// runRegressionGuard compares the DRAFT (first-stage translator output) with the FINAL (last-stage
// reflow output). Pure and deterministic (sorted detail), so a resume re-derives identical counts.
func runRegressionGuard(draft, final string) regressionGuardResult {
var r regressionGuardResult
// (a) length collapse — non-space chars, mirroring the coverage gate's length metric.
dn, fn := nonSpaceRuneCount(draft), nonSpaceRuneCount(final)
if dn >= regressionMinChars && fn*100 < dn*(100-regressionMaxShrinkPct) {
r.LengthCollapse = 1
r.Detail = append(r.Detail, fmt.Sprintf(
"reflow-регрессия: коллапс длины черновик→финал %d→%d непробельных символов (%d%%, порог %d%%) — возможен пропуск",
dn, fn, 100-fn*100/dn, regressionMaxShrinkPct))
}
// (b) number drift — multi-digit arabic tokens present on one side only.
dNums, fNums := arabicNumberTokens(draft), arabicNumberTokens(final)
disappeared := numberSetDiff(dNums, fNums)
appeared := numberSetDiff(fNums, dNums)
if len(disappeared) > 0 || len(appeared) > 0 {
r.NumberDrift = len(disappeared) + len(appeared)
if len(disappeared) > 0 {
r.Detail = append(r.Detail, "reflow-регрессия: числа черновика отсутствуют в финале (возможен пропуск/дрейф разряда): "+joinPreview(disappeared))
}
if len(appeared) > 0 {
r.Detail = append(r.Detail, "reflow-регрессия: в финале появились числа, которых не было в черновике (возможна добавка): "+joinPreview(appeared))
}
}
return r
}
// nonSpaceRuneCount counts non-whitespace runes — the length metric for the collapse flagger.
func nonSpaceRuneCount(s string) int {
n := 0
for _, r := range s {
if !unicode.IsSpace(r) {
n++
}
}
return n
}
// arabicNumberTokens returns the MULTI-DIGIT (≥2) arabic integers in text, stitching grouping
// separators (space / NBSP / comma) between runs of exactly three digits so "10 000" reads as one
// "10000", not "10" and "000". Single-digit numbers are dropped (routinely spelled out in prose).
func arabicNumberTokens(text string) []string {
rs := []rune(text)
var out []string
for i := 0; i < len(rs); {
if !isASCIIDigit(rs[i]) {
i++
continue
}
j := i
for j < len(rs) && isASCIIDigit(rs[j]) {
j++
}
digits := string(rs[i:j])
// Stitch " ddd" / " ddd" / ",ddd" grouped triples.
for j < len(rs) {
if rs[j] == ' ' || rs[j] == ' ' || rs[j] == ',' {
k := j + 1
g := 0
for k < len(rs) && isASCIIDigit(rs[k]) {
k++
g++
}
if g == 3 {
digits += string(rs[j+1 : k])
j = k
continue
}
}
break
}
if len(digits) >= 2 {
out = append(out, digits)
}
i = j
}
return out
}
// numberSetDiff returns the distinct members of a that do not appear in b, sorted. A SET diff (not
// a multiset): a number present on both sides is covered even if its multiplicity differs, keeping
// the precision-over-recall bias (a repeated number is not a drift signal on its own).
func numberSetDiff(a, b []string) []string {
inB := make(map[string]bool, len(b))
for _, x := range b {
inB[x] = true
}
seen := map[string]bool{}
var out []string
for _, x := range a {
if !inB[x] && !seen[x] {
seen[x] = true
out = append(out, x)
}
}
sort.Strings(out)
return out
}
// joinPreview renders a sorted number list compactly for the detail line (bounded).
func joinPreview(nums []string) string {
const max = 8
if len(nums) > max {
return preview(fmt.Sprintf("%v …(+%d)", nums[:max], len(nums)-max))
}
return fmt.Sprintf("%v", nums)
}

View file

@ -0,0 +1,82 @@
package pipeline
import (
"strings"
"testing"
)
// regressionguard_test.go — the post-reflow regression guard (D38). Precision over recall: each
// flagger asserts BOTH firing on a real regression AND non-firing on a legitimate reflow.
func TestRegressionGuardLengthCollapse(t *testing.T) {
// A draft well over the min-chars floor, halved by the "reflow" → collapse fires.
draft := strings.Repeat("Судзуки медленно шёл по коридору академии магии. ", 12) // ~560 non-space chars
shortFinal := "Судзуки шёл."
if r := runRegressionGuard(draft, shortFinal); r.LengthCollapse == 0 {
t.Errorf("expected a length-collapse flag (draft≫final)")
}
// A legitimate reflow that merges lines keeps ~all the content → must NOT fire.
sameFinal := strings.Repeat("Судзуки медленно шёл по коридору академии магии. ", 11)
if r := runRegressionGuard(draft, sameFinal); r.LengthCollapse != 0 {
t.Errorf("length-collapse FALSE POSITIVE on a near-equal reflow: %v", r.Detail)
}
// A short draft below the floor must never fire (a % ratio is meaningless there).
if r := runRegressionGuard("Он ушёл.", "Он."); r.LengthCollapse != 0 {
t.Errorf("length-collapse fired below the min-chars floor: %v", r.Detail)
}
}
func TestRegressionGuardNumberDrift(t *testing.T) {
// 四成四=44% rendered as «44» in the draft, drifted to a word form in the final → 44 disappears.
if r := runRegressionGuard("У него было 44 процента запаса.", "У него было четыре десятых запаса."); r.NumberDrift == 0 {
t.Errorf("expected a number-drift flag (44 vanished)")
}
// A number APPEARING in the final that was not in the draft (hallucinated figure).
if r := runRegressionGuard("Он собрал войско.", "Он собрал войско из 3000 солдат."); r.NumberDrift == 0 {
t.Errorf("expected a number-drift flag (3000 appeared)")
}
clean := []struct{ draft, final string }{
// The multi-digit number is preserved across the reflow (44 on both sides).
{"У него было 44 процента.", "У него имелось 44 процента запаса."},
// Grouped-triple vs joined form of the SAME number must not drift (10 000 ≡ 10000).
{"Армия в 10 000 воинов.", "Армия насчитывала 10000 воинов."},
// SINGLE digits are excluded (routinely spelled out), so «5»→«пять» is not a drift.
{"У него было 5 мечей.", "У него было пять мечей."},
// No numbers at all — never fires.
{"Судзуки шёл по коридору академии магии.", "Судзуки медленно шёл по длинному коридору."},
}
for _, c := range clean {
if r := runRegressionGuard(c.draft, c.final); r.NumberDrift != 0 {
t.Errorf("number-drift FALSE POSITIVE on %q→%q: %v", c.draft, c.final, r.Detail)
}
}
}
// TestRunCheapGatesRegressionOptIn pins that the guard is OFF by default and folds into the
// observability total only when enabled (a book that does not opt in serialises identically).
func TestRunCheapGatesRegressionOptIn(t *testing.T) {
draft := strings.Repeat("Судзуки медленно шёл по коридору академии магии. ", 12)
final := "Судзуки шёл."
off := runCheapGates("", draft, final, cheapGateConfig{yoPolicy: "auto", allowlist: map[string]bool{}})
if off.LengthCollapse != 0 || off.NumberDrift != 0 {
t.Errorf("regression guard fired while disabled: %+v", off)
}
on := runCheapGates("", draft, final, cheapGateConfig{yoPolicy: "auto", allowlist: map[string]bool{}, regressionEnabled: true})
if on.LengthCollapse == 0 {
t.Errorf("regression guard did not fire while enabled: %+v", on)
}
if on.total() <= off.total() {
t.Errorf("enabled guard must raise the observability total: off=%d on=%d", off.total(), on.total())
}
}
// TestRegressionGuardDeterministic — a pure function of (draft, final), resume-safe.
func TestRegressionGuardDeterministic(t *testing.T) {
d := strings.Repeat("текст с числом 44 и 128. ", 20)
f := "короткий финал без чисел"
a, b := runRegressionGuard(d, f), runRegressionGuard(d, f)
if a.LengthCollapse != b.LengthCollapse || a.NumberDrift != b.NumberDrift ||
strings.Join(a.Detail, "|") != strings.Join(b.Detail, "|") {
t.Fatalf("runRegressionGuard not deterministic: %+v vs %+v", a, b)
}
}

View file

@ -50,6 +50,26 @@ func (r *Runner) resumeFromChunkStatus(ctx context.Context, st config.Stage, ch
sr.Model = cp.ModelActual
sr.FinishReason = cp.FinishReason
sr.Text = cp.ResponseText
} else if cs.FlagReason == string(FlagSanitizerStripped) && cs.FinalHash != "" {
// A cosmetic sanitizer strip (D35.4a): the cleaned export text lives in the derived
// checkpoint final_hash points at (written durably before this chunk_status row), so resume
// re-serves the identical FinalText WITHOUT re-stripping — deterministic and $0. A missing
// derived checkpoint is a torn store (final_hash is written after it), so fail loud like the
// ok branch rather than silently export empty.
cp, err := r.Store.GetCheckpoint(cs.FinalHash)
if err != nil {
return nil, fmt.Errorf("pipeline: read sanitized-export checkpoint %.12s for %s/ch%d/chunk%d/%s: %w",
cs.FinalHash, r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, err)
}
if cp == nil || cp.ResponseText == "" {
return nil, fmt.Errorf("pipeline: chunk_status sanitizer_stripped for %s/ch%d/chunk%d/%s references derived checkpoint %.12s which is missing/empty — inconsistent store",
r.Book.BookID, ch.Chapter, ch.ChunkIdx, st.Name, cs.FinalHash)
}
sr.RecoveredText = cp.ResponseText
// Mirror the ok branch: the resume request_log row's request_hash points at this derived
// checkpoint, so report ITS finish_reason ("sanitized_export") rather than a bare "" that
// contradicts the referenced row (adversarial review NIT; telemetry-only).
sr.FinishReason = cp.FinishReason
}
rl := r.baseRequestLog(st, ch, st.Model, cs.FinalHash)
rl.ModelActual = sr.Model

View file

@ -18,7 +18,7 @@ import (
// (sanitizerVersion, mirroring coverageSnapshot), and moves to verdictSnapshotID once
// content-addressed resume lands (D15.2).
//
// Five classes, each tuned PRECISION over recall (fire only on a high-confidence signal;
// Six classes, each tuned PRECISION over recall (fire only on a high-confidence signal;
// the ambiguous middle stays silent — a false flag turns a good chunk into an export
// placeholder, worse than a missed defect for an opt-in readability gate):
//
@ -27,6 +27,21 @@ import (
// 3. markdown ### header — «### Глава 1» (8/54 finals of stage A — D29 finding)
// 4. Latin-script insertion — an untranslated Latin phrase/heavy Latin in the ru output
// 5. broken word form — homoglyph-mixed token or an invalid Cyrillic sign bigram
// 6. CJK-leak in the final — a stray Han ideograph / kana / fullwidth glyph in the ru output
// («…охотники,却有 деньги!» arms/C/17.0 — D38.1; below the ≥15%
// echo threshold classify() already catches, so a sparse leak)
//
// DISPOSITION-BY-CLASS (D38 infra-pack): the classes split into two tiers so a chunk is not
// silently lost to an export placeholder when the leak is a REMOVABLE cosmetic span (D35.4a —
// ch5/ch20 chapter openers dropped whole for a leading «### Глава N»):
// - COSMETIC (strippable, exported + flagged for a human, cosmeticOnly()==true): the markdown
// header (3) and the CJK-leak (6). The leak sits in a bounded span stripCosmetic() removes
// deterministically, leaving readable prose; the chunk is flagged sanitizer_stripped (a
// "auto-cleaned, verify" signal), NOT dropped.
// - SUBSTANTIVE (dropped to a placeholder, current behaviour): the preamble (1), trailing
// note (2), Latin insertion (4) and broken word (5). Their contamination has no reliable
// removable boundary (a preamble may swallow real narration; a Latin clause / broken token is
// lost content), so the output is not trusted — flagged sanitizer_defect + skipped.
//
// HONEST recall gap (documented, precision over recall): the broken-word class catches only
// the two ZERO-false-positive signatures (invalid Cyrillic sign bigrams, mixed Cyrillic/Latin
@ -43,7 +58,15 @@ import (
// v2 (D33.1/33.2): tail edit-summary «Основные правки для справки:» now flags; the
// junction-duplication split detector was removed (false-flagged prose); markdown/heavy-Latin
// narrowed. A verdict-axis change → loud --resnapshot + golden re-capture (invariant №8).
const sanitizerVersion = "sanitizer-v2"
// v3 (D38 infra-pack): added the CJK-leak class (Han/kana/fullwidth in the ru final) and the
// cosmetic-vs-substantive disposition split (the markdown header and CJK-leak are now STRIPPED
// and exported flagged, not dropped to an empty placeholder — D35.4a). Both shift the resolved
// disposition/export of a flagged chunk → a loud --resnapshot + golden re-capture (invariant №8).
// v4 (D38 infra-pack, adversarial review): stripCosmetic now FOLDS content-bearing fullwidth ASCII
// («»→«123», not deleted) and ideographic comma/period, space-replaces dropped ideographs (no
// word-merge), removes an emptied bracket pair, and no longer reformats a legit «2 : 1» — all shift
// a stripped chunk's EXPORT text → a loud --resnapshot + golden re-capture (invariant №8).
const sanitizerVersion = "sanitizer-v4"
// sanitizer tuning constants (versioned by sanitizerVersion).
const (
@ -72,11 +95,21 @@ type sanitizerResult struct {
MarkdownHeader int `json:"markdown_header,omitempty"`
LatinInsert int `json:"latin_insert,omitempty"`
BrokenWord int `json:"broken_word,omitempty"`
CJKLeak int `json:"cjk_leak,omitempty"`
Detail []string `json:"detail,omitempty"`
}
func (s sanitizerResult) total() int {
return s.Preamble + s.TrailingNote + s.MarkdownHeader + s.LatinInsert + s.BrokenWord
return s.Preamble + s.TrailingNote + s.MarkdownHeader + s.LatinInsert + s.BrokenWord + s.CJKLeak
}
// cosmeticOnly reports whether the ONLY classes that fired are the STRIPPABLE cosmetic ones
// (markdown header and/or CJK-leak) — the strip-and-export tier (D35.4a). A single substantive
// class (preamble/trailing-note/latin/broken-word) pulls the whole chunk into the skip tier: its
// contamination has no reliable removable boundary, so the output is not trusted even if a
// cosmetic leak also happens to be present. False when nothing fired.
func (s sanitizerResult) cosmeticOnly() bool {
return s.total() > 0 && s.Preamble == 0 && s.TrailingNote == 0 && s.LatinInsert == 0 && s.BrokenWord == 0
}
// summary is the human-readable disposition detail for a sanitizer flag (deterministic —
@ -91,7 +124,7 @@ func (s sanitizerResult) summary() string {
return strings.Join(s.Detail, "; ")
}
// sanitizeOutput runs all five classes over one chunk's FINAL translated text.
// sanitizeOutput runs all six classes over one chunk's FINAL translated text.
func sanitizeOutput(text string) sanitizerResult {
var r sanitizerResult
if det := detectLeadingPreamble(text); det != "" {
@ -114,6 +147,10 @@ func sanitizeOutput(text string) sanitizerResult {
r.BrokenWord = n
r.Detail = append(r.Detail, det...)
}
if n, det := detectCJKLeak(text); n > 0 {
r.CJKLeak = n
r.Detail = append(r.Detail, det...)
}
sort.Strings(r.Detail)
return r
}
@ -316,6 +353,125 @@ func detectBrokenWords(text string) (int, []string) {
return len(det), det
}
// --- 6. CJK-leak in the final --------------------------------------------------
// isCJKLeakRune reports whether r is a stray CJK glyph that has NO place in a Russian final: a
// Han ideograph, kana, a fullwidth/halfwidth form (U+FF00FFEF — «,()!» etc.), or a CJK
// symbol/punctuation (U+3001303F — «、。「」»). The ideographic SPACE U+3000 is deliberately
// EXCLUDED (it is an indent artifact, not a content leak — research/18 §C#4 "draft-21 includes a
// U+3000 indent → 9 real leaks"): stripCosmetic normalises it to a plain space, but it does not
// alone FIRE the class, so a legitimately-indented chunk is not flag-stormed. In clean Russian
// prose every one of these is count-0, so the class is high precision. The intrinsic classify()
// already flags a ≥15%-CJK output as an ECHO (cjk_artifact, substantive) BEFORE the sanitizer
// runs, so this class only ever sees a SPARSE leak in otherwise-Russian text.
func isCJKLeakRune(r rune) bool {
switch {
case unicode.Is(unicode.Han, r):
return true
case unicode.Is(unicode.Hiragana, r) || unicode.Is(unicode.Katakana, r):
return true
case r >= 0xFF00 && r <= 0xFFEF: // fullwidth & halfwidth forms
return true
case r >= 0x3001 && r <= 0x303F: // CJK symbols & punctuation, EXCLUDING U+3000 (ideographic space)
return true
}
return false
}
// detectCJKLeak counts maximal runs of leak runes in the final text and returns a deduped detail
// per distinct run. A single run («却有») is enough to fire — the class is COSMETIC, so the whole
// chunk is not lost: stripCosmetic removes the run and the remainder exports (flagged for review).
func detectCJKLeak(text string) (int, []string) {
var det []string
seen := map[string]bool{}
n := 0
rs := []rune(text)
for i := 0; i < len(rs); {
if !isCJKLeakRune(rs[i]) {
i++
continue
}
j := i
for j < len(rs) && isCJKLeakRune(rs[j]) {
j++
}
n++
run := string(rs[i:j])
if !seen[run] {
seen[run] = true
det = append(det, "CJK-утечка в ru-выходе: "+preview(run))
}
i = j
}
return n, det
}
// --- deterministic strip of the COSMETIC classes (D35.4a export fix) -------------
// leadingMarkdownStripRE matches the leading markdown ATX prefix on a line (optional indent, 16
// hashes, blank(s)) directly before a header WORD/number — the same shape markdownHeaderRE fires
// on. It captures the first header rune so ReplaceAll keeps the heading text, dropping only the
// «### » artifact («### Глава 5» → «Глава 5»). A decorative «### ---» scene break (punctuation
// after the hashes) does NOT match, so it is left intact (and it never fired the class either).
var leadingMarkdownStripRE = regexp.MustCompile(`(?m)^[ \t]{0,3}#{1,6}[ \t]+([\p{L}\p{Nd}])`)
// stripCosmetic deterministically removes the two STRIPPABLE cosmetic leak classes — leading
// markdown headers and CJK-leak runs — and tidies the whitespace the removal leaves, so the
// remainder is readable. It is a PURE function (resume re-derives the identical export), invoked
// ONLY on output the sanitizer classified as cosmeticOnly (classifyOutput guarantees the result
// is non-empty and clean — sanitizeOutput(stripCosmetic(x)).total()==0 — before tagging it
// sanitizer_stripped). It never crosses newlines when tidying, so paragraph structure is kept.
func stripCosmetic(text string) string {
// 1) markdown header prefixes → keep the heading text, drop the «#### » artifact.
out := leadingMarkdownStripRE.ReplaceAllString(text, "$1")
// 2) neutralise CJK-leak runes. CONTENT-BEARING glyphs are FOLDED, not dropped (adversarial
// review MAJOR): a fullwidth ASCII form is a wrong-glyph rendering of a plain char, so «123»
// folds to «123» (the number is kept, not deleted) and «,」→«,» — the same NFKC fold the
// memory normaliser uses. Only genuinely contentless runes (Han/kana/CJK brackets/symbols) are
// removed, and a removed run leaves a SINGLE SPACE so adjacent Russian words do not merge
// («нашёл特产древний» → «нашёл древний», not «нашёлдревний»). U+3000 → a plain space (indent).
var b strings.Builder
b.Grow(len(out))
for _, r := range out {
switch {
case r == ' ': // U+3000 ideographic space → plain space
b.WriteByte(' ')
case r >= 0xFF01 && r <= 0xFF5E: // fullwidth ASCII form → fold to plain ASCII (→1, →A, ,→,)
b.WriteRune(r - 0xFEE0)
case r == '、': // ideographic comma → plain comma (keep the clause boundary)
b.WriteByte(',')
case r == '。': // ideographic full stop → plain period
b.WriteByte('.')
case isCJKLeakRune(r): // Han / kana / CJK brackets / other symbol → drop, leaving a space
b.WriteByte(' ')
default:
b.WriteRune(r)
}
}
out = b.String()
// 3) tidy the seams the removal created, per line (horizontal whitespace only — never a
// newline): collapse doubled spaces, drop a bracket/quote pair the strip emptied («(羅漢拳)» →
// «()» → removed), drop a space now sitting before closing punctuation or after an opening
// bracket/quote (never-correct Russian spacing), and trim trailing blanks. ':' is deliberately
// NOT in the closing-punct set: a space-before-colon is legitimate in a Russian ratio/score/time
// («счёт 2 : 1») and must not be reformatted (adversarial review — false positive).
out = multiSpaceRE.ReplaceAllString(out, " ")
out = emptyBracketRE.ReplaceAllString(out, "")
out = spaceBeforePunctRE.ReplaceAllString(out, "$1")
out = spaceAfterOpenRE.ReplaceAllString(out, "$1")
out = multiSpaceRE.ReplaceAllString(out, " ") // an emptied bracket may leave a doubled space
out = trailingHSpaceRE.ReplaceAllString(out, "")
return out
}
var (
multiSpaceRE = regexp.MustCompile(`[ \t]{2,}`)
emptyBracketRE = regexp.MustCompile(`\([ \t]*\)|«[ \t]*»|\[[ \t]*\]`)
spaceBeforePunctRE = regexp.MustCompile(`[ \t]+([,.!?;…»)\]])`)
spaceAfterOpenRE = regexp.MustCompile(`([«(\[])[ \t]+`)
trailingHSpaceRE = regexp.MustCompile(`(?m)[ \t]+$`)
)
type scriptToken struct {
text string
cyr bool // Cyrillic-only

View file

@ -211,6 +211,100 @@ func TestSanitizerCleanNarrative(t *testing.T) {
}
}
// TestSanitizerCJKLeak — the D38.1 class: a stray Han/kana/fullwidth glyph in the ru final.
func TestSanitizerCJKLeak(t *testing.T) {
fire := []string{
// The span the orchestrator verified (arms/C/17.0): a raw Han run mid-Russian-sentence.
"«…охотники,却有 на это лишние деньги!»",
"Судзуки нашёл 特产 древнего клана.", // single Han run
"Разряд资质乙等 остался неясен.", // Han glued to Cyrillic
"Он сказал: すごい, и замолчал.", // kana leak (ja book)
"Цена была золотых.", // fullwidth digits
"Он вошёл в дом、и дверь закрылась.", // ideographic comma (U+3001)
}
for _, s := range fire {
if got := sanitizeOutput(s); got.CJKLeak == 0 {
t.Errorf("CJK-leak not detected in %q: %+v", preview(s), got)
}
}
clean := []string{
"Судзуки шёл по коридорам Академии магии, вспоминая вчерашнюю долгую лекцию.",
"— Сегодня я открою дверь книгохранилища, — прошептал он, сжимая ключ.",
"На гербе была латинская надпись, но он её не разобрал в темноте.",
"Цена была 123 золотых, и он расплатился не торгуясь.", // ASCII digits — not fullwidth
// U+3000 ideographic space alone is an INDENT artifact, NOT a content leak → must NOT fire
// (research/18 §C#4); stripCosmetic still normalises it, but it does not flag the chunk.
" Судзуки медленно шёл по длинному коридору к башне.",
}
for _, s := range clean {
if got := sanitizeOutput(s); got.CJKLeak != 0 {
t.Errorf("CJK-leak FALSE POSITIVE in %q: %v", preview(s), got.Detail)
}
}
}
// TestSanitizerCosmeticOnly pins the strip-vs-skip disposition matrix (D35.4a): only the markdown
// header and CJK-leak classes are strippable; any substantive class pulls the chunk into skip.
func TestSanitizerCosmeticOnly(t *testing.T) {
cosmetic := []string{
"### Глава 7\n\nСудзуки открыл седьмую дверь и замер на пороге.", // markdown only
"Судзуки нашёл 特产 древнего клана в тёмном углу книгохранилища.", // cjk only
"### Пролог\n\nСудзуки увидел 却有 на алтаре забытого клана.", // BOTH cosmetic classes
}
for _, s := range cosmetic {
got := sanitizeOutput(s)
if got.total() == 0 || !got.cosmeticOnly() {
t.Errorf("expected cosmeticOnly for %q: %+v", preview(s), got)
}
}
substantive := []string{
"Вот перевод фрагмента: Судзуки открыл дверь.", // preamble (substantive)
// a LEADING preamble co-occurring with a CJK leak → the substantive class wins (skip, not strip).
"Вот перевод фрагмента: Судзуки увидел 特产 на алтаре забытого клана.",
"Судзуки открыл дверь.\n\nПримечание: имя героя оставлено как есть.", // trailing note
"Он прочитал: the quick brown fox jumps over lazy dog today.", // latin phrase
}
for _, s := range substantive {
if got := sanitizeOutput(s); got.total() == 0 || got.cosmeticOnly() {
t.Errorf("expected NOT cosmeticOnly (substantive) for %q: %+v", preview(s), got)
}
}
}
// TestStripCosmetic pins the exact deterministic strip output — the "экспортит текст БЕЗ ведущего
// ###" contract — and that the result is itself clean (the classifyOutput guarantee).
func TestStripCosmetic(t *testing.T) {
cases := []struct{ in, want string }{
// markdown header prefix removed, heading text kept.
{"### Глава 7\n\nСудзуки открыл дверь.", "Глава 7\n\nСудзуки открыл дверь."},
{"# Пролог\nТекст главы.", "Пролог\nТекст главы."},
// Han run removed; the seam is tidied (no double space, no space-before-comma), no word-merge.
{"«…охотники,却有 на это лишние деньги!»", "«…охотники, на это лишние деньги!»"},
{"Судзуки нашёл 特产 древнего клана.", "Судзуки нашёл древнего клана."},
{"нашёл特产древний", "нашёл древний"}, // no spaces in source → strip leaves a space, not a merge
// CONTENT-BEARING fullwidth ASCII is FOLDED, not deleted (adversarial review MAJOR): the number survives.
{"Цена была золотых.", "Цена была 123 золотых."},
{"Индекс найден.", "Индекс ABC найден."},
// ideographic comma/period fold to plain — the clause boundary is kept.
{"Он вошёл в дом、и дверь закрылась。", "Он вошёл в дом,и дверь закрылась."},
// a parenthetical Han gloss is stripped and the emptied «()» removed (adversarial review).
{"Кулак Ло Хань (羅漢拳) был силён.", "Кулак Ло Хань был силён."},
// a legit ratio «2 : 1» is NOT reformatted (colon dropped from the tidy set).
{"### Итог\n\nСчёт был 2 : 1 却有 в пользу гостей.", "Итог\n\nСчёт был 2 : 1 в пользу гостей."},
// U+3000 ideographic space normalised to a plain space (the indent artifact).
{" Судзуки шёл.", " Судзуки шёл."},
}
for _, c := range cases {
got := stripCosmetic(c.in)
if got != c.want {
t.Errorf("stripCosmetic(%q) = %q, want %q", c.in, got, c.want)
}
if san := sanitizeOutput(got); san.total() != 0 {
t.Errorf("stripCosmetic(%q) is not clean: %+v", c.in, san.Detail)
}
}
}
// TestSanitizerDeterministic asserts the verdict is a pure function of the text (resume-safe).
func TestSanitizerDeterministic(t *testing.T) {
s := "### Заголовок\nВот отредактированный перевод: текст. Стольь странно.\nПримечание: правка."

View file

@ -2,6 +2,8 @@ package pipeline
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@ -149,8 +151,12 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
runCost += esc.fb.runCost
anyFresh = anyFresh || esc.fb.freshCall
escalated, escModel = true, st.EscalateTo
if esc.fb.cls.ok() {
last = esc.fb // the fallback draft passed the re-gate → it is authoritative
if esc.fb.cls.ok() || esc.fb.cls.Reason == FlagSanitizerStripped {
// The fallback is authoritative when it passed the re-gate OR when it is a cosmetic
// sanitizer strip on a FINAL escalatable stage (adversarial review): a stripped fallback
// is a usable-but-flagged export, so it must be recovered (final_hash → its cleaned text)
// rather than discarded to an empty placeholder — mirroring the non-escalated path.
last = esc.fb
}
// else: the fallback also failed → keep the primary flag (last unchanged);
// the fallback call is billed and counted, the chunk stays flagged (1 hop).
@ -158,12 +164,27 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
disposition := last.cls.Reason.disposition()
finalHash := ""
recovered := ""
if disposition == DispOK {
finalHash = last.reqHash
} else if last.cls.Reason == FlagSanitizerStripped {
// Cosmetic leak (D35.4a): strip it and COMMIT the cleaned remainder as a $0 derived export
// checkpoint, then point final_hash at it — so the standard final_hash→checkpoint.response_text
// export (records.json / exp12_extract) yields the cleaned text instead of empty, while the
// chunk stays flagged for a human. classifyOutput guaranteed the strip is non-empty and clean.
// The derived checkpoint is durably written BEFORE chunk_status references it (the ok path's
// checkpoint-before-pointer discipline), so a resume never dangles final_hash at a missing row.
recovered = stripCosmetic(last.text)
dh, err := r.commitSanitizedExport(st, ch, job, last, attemptsMade-1, recovered)
if err != nil {
return nil, err
}
finalHash = dh
}
// chunk_status is a RESOLVE over the checkpoints: cost_usd sums every attempt
// (F3-honest — retries are counted), final_hash points at the authoritative
// checkpoint the ok path serves on resume.
// checkpoint the ok path serves on resume (or the derived sanitized-export checkpoint for
// a FlagSanitizerStripped chunk, so its cleaned text still exports).
if err := r.Store.UpsertChunkStatus(store.ChunkStatus{
BookID: r.Book.BookID, Chapter: ch.Chapter, ChunkIdx: ch.ChunkIdx, Stage: st.Name,
SnapshotID: snapID, ContentHash: contentHash, Disposition: string(disposition), FlagReason: string(last.cls.Reason),
@ -181,6 +202,7 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
CumCostUSD: cumCost,
LatencyMS: last.latency,
FinishReason: last.finish,
RecoveredText: recovered,
Disposition: disposition,
FlagReason: last.cls.Reason,
Detail: last.cls.Detail,
@ -198,6 +220,24 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
return sr, nil
}
// commitSanitizedExport persists the cosmetic-stripped export text as a $0 derived checkpoint and
// returns its request_hash (→ chunk_status.final_hash). The hash is CONTENT-ADDRESSED and
// namespaced ("tm-sanitized-v1:") so it can never collide with a real attempt's hex request_hash
// and a resume/re-run re-derives the identical id for free. Called ONLY for a FlagSanitizerStripped
// final stage (D35.4a); `att` is the flagged completion the strip ran over.
func (r *Runner) commitSanitizedExport(st config.Stage, ch Chunk, job *store.Job, att stageAttempt, attempt int, stripped string) (string, error) {
sum := sha256.Sum256([]byte("tm-sanitized-v1\x00" + att.reqHash + "\x00" + stripped))
derivedHash := "tm-sanitized-v1:" + hex.EncodeToString(sum[:])
if err := r.Store.PutDerivedCheckpoint(store.Checkpoint{
RequestHash: derivedHash, JobID: job.ID, ChunkIdx: ch.ChunkIdx, Attempt: attempt,
Stage: st.Name, Role: st.Role, ModelRequested: att.modelActual, ModelActual: att.modelActual,
ResponseText: stripped, UsageJSON: "{}", FinishReason: "sanitized_export",
}); err != nil {
return "", fmt.Errorf("pipeline: commit sanitized export ch%d/chunk%d/%s: %w", ch.Chapter, ch.ChunkIdx, st.Name, err)
}
return derivedHash, nil
}
// stageAttempt is the result of one attempt (a checkpoint hit or a fresh call).
type stageAttempt struct {
reqHash string

View file

@ -104,7 +104,7 @@ func flagReasonSeverity(reason string) int {
case FlagCJKArtifact, FlagExcisionSuspect, FlagCoverageFail:
return 1
case FlagSanitizerDefect:
// A contaminated output (leaked preamble / notes / ###) is unreadable-as-shipped —
// A contaminated output (leaked preamble / notes) that was DROPPED is unreadable-as-shipped —
// ranked with the deterministic content failures, above a mere budget symptom.
return 2
case FlagLoopDegenerate:
@ -113,10 +113,15 @@ func flagReasonSeverity(reason string) int {
return 4
case FlagGlossaryMiss:
return 5
case FlagSanitizerStripped:
// A cosmetic leak the sanitizer STRIPPED and exported (D35.4a): the chunk shipped cleaned,
// so it is the least alarming flag — an "auto-cleaned, glance to verify" signal, ranked below
// a budget symptom (the chunk is not lost; a human need only spot-check the auto-clean).
return 7
case FlagLength, FlagEmpty:
return 6
}
return 7
return 8
}
// bookChunks re-derives the book's chunk manifest — Ingest + SplitChunks — for the honest N/M

File diff suppressed because one or more lines are too long

View file

@ -41,3 +41,11 @@
第六章 序文漏洩
鈴木は最後の扉の前に立ち、深く息を吸い込んだ。その先に何があるのか、誰も知らなかった。
第七章 見出漏洩
七層目の朝、鈴木は最後の書庫の扉を開けた。扉の奥には見出しだけが刻まれた石板が静かに待っていた。誰もその文字を読めなかった。
第八章 漢字漏洩
鈴木は石板の文字をなぞり、古い漢字の意味を思い出そうとした。だが記憶は霧のように薄れ、指先だけが淡く光っていた。

View file

@ -170,6 +170,33 @@ func (s *Store) SettleWithCheckpoint(res Reservation, cost float64, cp Checkpoin
return tx.Commit()
}
// PutDerivedCheckpoint persists a $0 DERIVED checkpoint — a deterministic post-processing
// artifact, NOT a billed provider response (D38 infra-pack): the output-sanitizer's cosmetic
// strip commits the cleaned final text here so the standard final_hash→checkpoint.response_text
// export contract (records.json / exp12_extract) yields the cleaned text for a flagged chunk that
// would otherwise export empty (D35.4a). It touches NO spend/reservation (cost must be 0), keyed
// by its own derived request_hash (namespaced, collision-free with real attempt hashes), and is
// idempotent (ON CONFLICT DO NOTHING) so a re-run re-derives the identical row for free. Escalation
// is deliberately false so it never counts toward escalation.budget_usd. It is deleted with its
// stage's real checkpoints on `redrive` (ResetChunkStages joins by job/chunk/stage), never orphaned.
func (s *Store) PutDerivedCheckpoint(cp Checkpoint) error {
ctx, cancel := opContext()
defer cancel()
_, err := s.w.ExecContext(ctx, `
INSERT INTO checkpoints (
request_hash, job_id, chunk_idx, attempt, stage, role,
model_requested, model_actual, response_text, usage_json,
cost_usd, finish_reason, provider_request_id, escalation
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, '', 0)
ON CONFLICT (request_hash) DO NOTHING`,
cp.RequestHash, cp.JobID, cp.ChunkIdx, cp.Attempt, cp.Stage, cp.Role,
cp.ModelRequested, cp.ModelActual, cp.ResponseText, cp.UsageJSON, cp.FinishReason)
if err != nil {
return fmt.Errorf("store: derived checkpoint insert: %w", err)
}
return nil
}
// GetCheckpoint returns the persisted response for a request hash, if any —
// the resume path: a hit means the call is NOT repeated or re-billed.
func (s *Store) GetCheckpoint(requestHash string) (*Checkpoint, error) {

View file

@ -73,6 +73,41 @@ func TestReserveSettleLifecycle(t *testing.T) {
}
}
// TestPutDerivedCheckpoint pins the $0 sanitized-export checkpoint (D38 infra-pack): it persists a
// retrievable response, books NO spend, and is idempotent (re-derive on resume is free).
func TestPutDerivedCheckpoint(t *testing.T) {
s, _ := openTemp(t)
job := mustSnapshotAndJob(t, s)
cp := Checkpoint{RequestHash: "tm-sanitized-v1:abc", JobID: job.ID, ChunkIdx: 0, Stage: "edit",
Role: "editor", ModelRequested: "m", ModelActual: "m", ResponseText: "очищенный текст",
UsageJSON: "{}", FinishReason: "sanitized_export"}
if err := s.PutDerivedCheckpoint(cp); err != nil {
t.Fatal(err)
}
// Retrievable via the standard checkpoint read (the final_hash→checkpoint export contract).
got, err := s.GetCheckpoint("tm-sanitized-v1:abc")
if err != nil || got == nil || got.ResponseText != "очищенный текст" {
t.Fatalf("derived checkpoint: %+v err=%v", got, err)
}
if got.CostUSD != 0 {
t.Fatalf("derived checkpoint must be $0, got %v", got.CostUSD)
}
// No spend booked (it is a post-processing artifact, not a provider call).
committed, reserved, _ := s.SpentUSD("book")
if committed != 0 || reserved != 0 {
t.Fatalf("derived checkpoint booked spend: committed=%v reserved=%v", committed, reserved)
}
// Idempotent: a resume re-derives the identical row for free (ON CONFLICT DO NOTHING).
if err := s.PutDerivedCheckpoint(cp); err != nil {
t.Fatalf("re-put must be idempotent: %v", err)
}
committed, _, _ = s.SpentUSD("book")
if committed != 0 {
t.Fatalf("idempotent re-put booked spend: committed=%v", committed)
}
}
func TestCeilingsDeny(t *testing.T) {
s, _ := openTemp(t)
mustSnapshotAndJob(t, s)