Land pack eighteen as D39.31: rebill consent gate before money, authoritative escalation model with machine-verified golden recapture, generality nits deferred by measurement

This commit is contained in:
Claude (backend session) 2026-07-25 19:12:00 +03:00
parent 3bc9eb48b1
commit dad4b56e0d
22 changed files with 1768 additions and 54 deletions

View file

@ -14,7 +14,7 @@ Go-бэкенд издательского художественного пер
## Источники истины (по убыванию)
1. **`docs/architecture/05-decisions-log.md` (D1D39.30)** — ратифицированный контракт; при конфликте с любым другим доком побеждает он; сверху файла — карта актуальности (что чем superseded). **Правило чтения (онбординг-диета 25.07): карта + живая голова (эра D39.x); корпус D1D38 = справочник — по ссылкам/grep по D-номеру, целиком НЕ читать.**
1. **`docs/architecture/05-decisions-log.md` (D1D39.31)** — ратифицированный контракт; при конфликте с любым другим доком побеждает он; сверху файла — карта актуальности (что чем superseded). **Правило чтения (онбординг-диета 25.07): карта + живая голова (эра D39.x); корпус D1D38 = справочник — по ссылкам/grep по D-номеру, целиком НЕ читать.**
2. `docs/experiments/00-provider-quirks.md` — wire-квирки провайдеров (читать ПЕРЕД правкой адаптеров/вызовами провайдеров).
3. `docs/architecture/09-target-architecture.md` (7 слоёв; статус стройки — шапка-таблица) · `12-go-style-notes.md` (норматив общности §0) · `10-prompt-architecture.md` (промпт-тема).
4. `docs/architecture/01-decisions.md` (Р1Р10), `02-mvp-plan.md`, `04-unhappy-paths.md`, `06-memory-risk-registry.md` — тела исторические, читать через ⚠-баннеры 25.07.

View file

@ -4,6 +4,8 @@ import (
"flag"
"fmt"
"io"
"math"
"strconv"
"textmachine/backend/internal/pipeline"
)
@ -15,16 +17,57 @@ import (
// invocation is one parsed tmctl command line.
type invocation struct {
cmd string
cfgPath string
seedPath string // seed-lint: the glossary seed YAML to validate (no --config)
resnapshot bool
asJSON bool
asPlaintext bool
asPairs bool // export: include the source column (--pairs) for the DC1/DC2 FP-measure
sel pipeline.RedriveSelector
cmd string
cfgPath string
seedPath string // seed-lint: the glossary seed YAML to validate (no --config)
resnapshot bool
acceptRebill pipeline.RebillConsent // --accept-rebill[=usd]: Р6 consent to a projected re-payment
asJSON bool
asPlaintext bool
asPairs bool // export: include the source column (--pairs) for the DC1/DC2 FP-measure
sel pipeline.RedriveSelector
}
// rebillConsentValue parses `--accept-rebill[=usd]` (D20.2-Q2): the OPTIONAL-VALUE form of Р6, where a
// bare flag consents to the whole projected re-payment and `--accept-rebill=1.50` consents only while
// the projection stays at or below $1.50. It is a flag.Value with IsBoolFlag, which is the only way the
// stdlib grants a flag an optional value: bare, the package calls Set("true").
type rebillConsentValue struct{ c pipeline.RebillConsent }
func (v *rebillConsentValue) String() string {
switch {
case v == nil || !v.c.Given:
return "false"
case v.c.Capped:
return strconv.FormatFloat(v.c.CapUSD, 'g', -1, 64)
default:
return "true"
}
}
func (v *rebillConsentValue) Set(s string) error {
switch s {
case "true":
v.c = pipeline.RebillConsent{Given: true}
return nil
case "false":
v.c = pipeline.RebillConsent{}
return nil
}
usd, err := strconv.ParseFloat(s, 64)
if err != nil || math.IsNaN(usd) || math.IsInf(usd, 0) || usd < 0 {
return fmt.Errorf("--accept-rebill takes a non-negative USD ceiling (--accept-rebill=1.50) or no value at all; got %q", s)
}
v.c = pipeline.RebillConsent{Given: true, Capped: true, CapUSD: usd}
return nil
}
// IsBoolFlag lets `--accept-rebill` stand alone. The cost of that stdlib affordance is that
// `--accept-rebill 1.50` does NOT bind the amount — the flag reads as bare and 1.50 becomes a stray
// argument — which would silently turn a capped consent into an unlimited one. parseInvocation refuses
// that spelling explicitly rather than let it read as consent to everything.
func (v *rebillConsentValue) IsBoolFlag() bool { return true }
// parseInvocation parses os.Args[1:] into an invocation. flagOut receives the
// stdlib's flag diagnostics ("flag provided but not defined" + usage) — main
// passes os.Stderr, tests a buffer; the bytes and their destination are part of
@ -48,6 +91,8 @@ func parseInvocation(args []string, flagOut io.Writer) (invocation, error) {
fs.SetOutput(flagOut)
cfgPath := fs.String("config", "", "path to book.yaml")
resnapshot := fs.Bool("resnapshot", false, "re-pin existing jobs to the current config snapshot (re-translates already-paid chunks — explicit consent)")
acceptRebill := &rebillConsentValue{}
fs.Var(acceptRebill, "accept-rebill", "translate/redrive: consent to the projected RE-PAYMENT of already-billed work (D20.2-Q2). Bare accepts the whole projected amount; --accept-rebill=1.50 accepts it only up to $1.50")
asJSON := fs.Bool("json", false, "status: emit the projection as JSON (stable disposition/flag_reason enums) for CI/IDE")
asPlaintext := fs.Bool("plaintext", false, "export: emit the concatenated human text instead of the default stable JSON")
asPairs := fs.Bool("pairs", false, "export: include the source text per chunk (src↔target column for the DC1/DC2 FP-measure, WS5)")
@ -69,8 +114,15 @@ func parseInvocation(args []string, flagOut io.Writer) (invocation, error) {
if *cfgPath == "" {
return invocation{}, fmt.Errorf("--config book.yaml is required")
}
// The optional-value trap (see IsBoolFlag): `--accept-rebill 1.50` leaves 1.50 as a stray argument
// and the consent unlimited. Refuse loud rather than bill the difference. Scoped to exactly that
// spelling, so the historically-tolerated stray argument stays tolerated everywhere else.
if acceptRebill.c.Given && !acceptRebill.c.Capped && fs.NArg() > 0 {
return invocation{}, fmt.Errorf("--accept-rebill takes its ceiling with an «=» (--accept-rebill=%s), not a space: as written %q is a stray argument and the flag consents to the FULL projected re-payment", fs.Arg(0), fs.Arg(0))
}
return invocation{
cmd: cmd, cfgPath: *cfgPath, resnapshot: *resnapshot, asJSON: *asJSON, asPlaintext: *asPlaintext, asPairs: *asPairs,
cmd: cmd, cfgPath: *cfgPath, resnapshot: *resnapshot, acceptRebill: acceptRebill.c,
asJSON: *asJSON, asPlaintext: *asPlaintext, asPairs: *asPairs,
sel: pipeline.RedriveSelector{
Chapter: *chapter, ChunkIdx: *chunk, Reason: *reason, DryRun: *dryRun,
},

View file

@ -92,6 +92,63 @@ func TestParseRedriveSelectorExplicit(t *testing.T) {
}
}
// TestParseAcceptRebillForms pins the OPTIONAL-VALUE contract of --accept-rebill[=usd] (D20.2-Q2):
// bare = consent to the whole projected amount, `=usd` = consent only up to that ceiling, absent =
// no consent. The three states are distinct because the gate treats them differently.
func TestParseAcceptRebillForms(t *testing.T) {
for _, tc := range []struct {
args []string
want pipeline.RebillConsent
}{
{[]string{"translate", "--config", "b.yaml"}, pipeline.RebillConsent{}},
{[]string{"translate", "--config", "b.yaml", "--accept-rebill"}, pipeline.RebillConsent{Given: true}},
{[]string{"translate", "--config", "b.yaml", "--accept-rebill=1.50"}, pipeline.RebillConsent{Given: true, Capped: true, CapUSD: 1.50}},
// An explicit ZERO ceiling is a real answer ("only if it costs nothing"), not "no flag".
{[]string{"translate", "--config", "b.yaml", "--accept-rebill=0"}, pipeline.RebillConsent{Given: true, Capped: true, CapUSD: 0}},
{[]string{"redrive", "--config", "b.yaml", "--accept-rebill=0.25", "--resnapshot"}, pipeline.RebillConsent{Given: true, Capped: true, CapUSD: 0.25}},
} {
inv, err := parseInvocation(tc.args, &bytes.Buffer{})
if err != nil {
t.Fatalf("%v: %v", tc.args, err)
}
if inv.acceptRebill != tc.want {
t.Errorf("%v → %+v, want %+v", tc.args, inv.acceptRebill, tc.want)
}
}
}
// TestParseAcceptRebillSpaceFormRefused is the money trap of an IsBoolFlag optional value: written with
// a SPACE the amount does not bind, the flag reads as bare, and a capped consent silently becomes an
// unlimited one. It must be an error, not a $-sized surprise.
func TestParseAcceptRebillSpaceFormRefused(t *testing.T) {
_, err := parseInvocation([]string{"translate", "--config", "b.yaml", "--accept-rebill", "1.50"}, &bytes.Buffer{})
if err == nil {
t.Fatal("`--accept-rebill 1.50` must not silently read as an UNLIMITED consent")
}
if !strings.Contains(err.Error(), "--accept-rebill=1.50") {
t.Errorf("the error must show the correct spelling; got: %v", err)
}
if code := exitCode(err); code != 1 {
t.Errorf("a parse error is exit 1, got %d", code)
}
// The same stray argument WITHOUT the bare flag stays tolerated (the frozen contract is untouched).
if _, err := parseInvocation([]string{"translate", "--config", "b.yaml", "stray"}, &bytes.Buffer{}); err != nil {
t.Errorf("a stray argument outside the --accept-rebill spelling must stay tolerated: %v", err)
}
}
// TestParseAcceptRebillRejectsBadCeiling: a ceiling that is not a non-negative number is a typo about
// MONEY — refuse rather than fall back to some default consent.
func TestParseAcceptRebillRejectsBadCeiling(t *testing.T) {
for _, bad := range []string{"abc", "-1", "1,50", "NaN", "Inf"} {
var diag bytes.Buffer
_, err := parseInvocation([]string{"translate", "--config", "b.yaml", "--accept-rebill=" + bad}, &diag)
if err == nil {
t.Errorf("--accept-rebill=%s must be refused", bad)
}
}
}
func TestExitCodeContract(t *testing.T) {
if exitCode(nil) != 0 {
t.Fatal("nil → 0")

View file

@ -71,7 +71,7 @@ func run() error {
switch inv.cmd {
case "translate":
return translate(ctx, inv.cfgPath, inv.resnapshot)
return translate(ctx, inv.cfgPath, inv.resnapshot, inv.acceptRebill)
case "report":
return report(inv.cfgPath)
case "status":
@ -79,7 +79,7 @@ func run() error {
case "export":
return export(inv.cfgPath, inv.asPlaintext, inv.asPairs)
case "redrive":
return redrive(ctx, inv.cfgPath, inv.resnapshot, inv.sel)
return redrive(ctx, inv.cfgPath, inv.resnapshot, inv.acceptRebill, inv.sel)
case "seed-lint":
return seedLint(inv.seedPath)
default:
@ -87,13 +87,18 @@ func run() error {
}
}
func translate(ctx context.Context, cfgPath string, resnapshot bool) error {
// translate runs the book. The two money flags are ORTHOGONAL (D20.2-Q2): --resnapshot grants
// permission to re-pin jobs onto the current config snapshot, while --accept-rebill[=usd] consents to
// the AMOUNT that re-pin would re-pay. Over the book's consent threshold the run stops with the sum
// before reserving anything — a flag that names no money cannot carry a Р6 consent to a spend.
func translate(ctx context.Context, cfgPath string, resnapshot bool, acceptRebill pipeline.RebillConsent) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
r.Resnapshot = resnapshot
r.AcceptRebill = acceptRebill
res, err := r.TranslateBook(ctx)
if err != nil {
@ -198,13 +203,16 @@ func status(ctx context.Context, cfgPath string, asJSON bool) error {
// flagged rows carry (else it fails loud); passing --resnapshot wires r.Resnapshot so Redrive skips
// that drift guard and accepts the re-pin/re-pay explicitly (D20.4: the flag used to be parsed but
// silently ignored — Redrive already honoured r.Resnapshot, only the CLI never set it).
func redrive(ctx context.Context, cfgPath string, resnapshot bool, sel pipeline.RedriveSelector) error {
// --accept-rebill[=usd] is the separate consent to the AMOUNT such a re-pin re-pays (D20.2-Q2): over
// the threshold Redrive refuses BEFORE its destructive reset, so the flag telemetry survives a refusal.
func redrive(ctx context.Context, cfgPath string, resnapshot bool, acceptRebill pipeline.RebillConsent, sel pipeline.RedriveSelector) error {
r, err := pipeline.NewRunner(cfgPath, obs.NewLogger())
if err != nil {
return err
}
defer r.Close()
r.Resnapshot = resnapshot
r.AcceptRebill = acceptRebill
summary, res, err := r.Redrive(ctx, sel)
if err != nil {

View file

@ -0,0 +1,193 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"time"
"textmachine/backend/internal/pipeline"
)
// rebill_cli_test.go closes the D20.4 «parsed but silently ignored» regression class for
// --accept-rebill: parsing the flag correctly is worthless if the value never reaches the runner. The
// invocation tests cover the parse; this one drives the REAL `translate` entry point end-to-end against
// a mock provider, so the wiring itself is executed, not asserted about.
// writeCLIFile writes a fixture file, creating parents.
func writeCLIFile(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
// setupCLIProject builds the smallest runnable project (one draft stage) pointed at providerURL and
// returns the path of its book.yaml.
func setupCLIProject(t *testing.T, providerURL string) string {
t.Helper()
dir := t.TempDir()
writeCLIFile(t, filepath.Join(dir, "prompts", "translator.md"),
"Переводи с {{source_lang}} на {{target_lang}}.\n---USER---\n{{text}}")
writeCLIFile(t, filepath.Join(dir, "models.yaml"), fmt.Sprintf(`
prices_checked: %q
default_model: fake-model
providers:
fake:
kind: openai
base_url: %q
timeouts: { attempt_s: 5, max_attempts: 2, backoff_cap_s: 1 }
models:
fake-model:
provider: fake
price: { input_per_m: 1.0, cached_per_m: 0.1, cache_write_per_m: 0, output_per_m: 2.0 }
`, time.Now().UTC().Format("2006-01-02"), providerURL))
writeCLIFile(t, filepath.Join(dir, "pipeline.yaml"), `
core: C1
version: 1
defaults: { max_output_ratio: 2.0, min_max_tokens: 512 }
retries: { regenerate_before_escalate: 0 }
context: { glossary_injection: selective, glossary_token_budget: 800 }
stages:
- { name: draft, role: translator, model: fake-model, prompt_override: prompts/translator.md, prompt_version: v-cli, temperature: 0.3, reasoning: "off" }
`)
writeCLIFile(t, filepath.Join(dir, "source.txt"), "静かな図書館の朝。")
writeCLIFile(t, filepath.Join(dir, "book.yaml"), `
book_id: cli-book
title: Тест
source_lang: ja
target_lang: ru
genre: ранобэ
audience: тест
venuti: 0.5
honorifics: keep
transcription: polivanov
footnotes: minimal
pipeline: pipeline.yaml
models: models.yaml
source_file: source.txt
ceilings: { book_usd: 1.0, day_usd: 2.0 }
`)
return filepath.Join(dir, "book.yaml")
}
// TestTranslateWiresAcceptRebill: after a config drift, `translate --resnapshot` alone must be REFUSED
// with the projected sum, and the same call carrying the consent must go through. Dropping
// `r.AcceptRebill = acceptRebill` in main.go makes the second half fail here — which is the whole point:
// the flag exists to change what the run does, not what the parser returns.
func TestTranslateWiresAcceptRebill(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.ReadAll(r.Body)
calls++
tb, _ := json.Marshal("ЧЕРНОВИК ПЕРЕВОДА")
fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":"stop"}],
"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`, tb)
}))
defer srv.Close()
bookPath := setupCLIProject(t, srv.URL)
ctx := context.Background()
if err := translate(ctx, bookPath, false, pipeline.RebillConsent{}); err != nil {
t.Fatalf("first run: %v", err)
}
if calls != 1 {
t.Fatalf("setup: the first run must bill one call, got %d", calls)
}
// Drift the config so the stored row's snapshot is superseded.
pipePath := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(pipePath)
if err != nil {
t.Fatal(err)
}
writeCLIFile(t, pipePath, strings.Replace(string(raw), "prompt_version: v-cli", "prompt_version: v-cli-drift", 1))
// --resnapshot WITHOUT consent → refused, nothing called.
err = translate(ctx, bookPath, true, pipeline.RebillConsent{})
if err == nil {
t.Fatal("`translate --resnapshot` without --accept-rebill must be refused over the threshold")
}
if !strings.Contains(err.Error(), "--accept-rebill") {
t.Errorf("the refusal must name the flag; got: %v", err)
}
if calls != 1 {
t.Fatalf("a refused translate must reach no provider, calls=%d", calls)
}
// A ceiling BELOW the projection is still a refusal — proving the CAPPED value is wired, not just
// the boolean.
if err := translate(ctx, bookPath, true, pipeline.RebillConsent{Given: true, Capped: true, CapUSD: 0.0001}); err == nil {
t.Fatal("a ceiling below the projection must be refused through the CLI entry point too")
}
if calls != 1 {
t.Fatalf("a cap-refused translate must reach no provider, calls=%d", calls)
}
// Consent given → the run proceeds and really re-pays.
if err := translate(ctx, bookPath, true, pipeline.RebillConsent{Given: true}); err != nil {
t.Fatalf("`translate --resnapshot --accept-rebill` must proceed: %v", err)
}
if calls != 2 {
t.Fatalf("the consented run must re-call the provider, calls=%d", calls)
}
}
// TestRedriveWiresAcceptRebill is the same wiring check for the OTHER money-spending command: redrive
// carries --resnapshot too, so it carries the consent too. A redrive refused for want of consent must
// also leave the flagged row it was about to reset intact.
func TestRedriveWiresAcceptRebill(t *testing.T) {
calls := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.ReadAll(r.Body)
calls++
tb, _ := json.Marshal("Извините, я не могу перевести это.")
fmt.Fprintf(w, `{"id":"fake","model":"fake-model","choices":[{"message":{"content":%s},"finish_reason":"stop"}],
"usage":{"prompt_tokens":1000,"completion_tokens":500,"prompt_tokens_details":{"cached_tokens":200}}}`, tb)
}))
defer srv.Close()
bookPath := setupCLIProject(t, srv.URL)
ctx := context.Background()
// The refusal text flags the only chunk (exit 2 is a sentinel, not an infra failure).
if err := translate(ctx, bookPath, false, pipeline.RebillConsent{}); exitCode(err) != 2 {
t.Fatalf("setup: the refusal fixture must complete with flags (exit 2), got %v", err)
}
callsAfterRun1 := calls
pipePath := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(pipePath)
if err != nil {
t.Fatal(err)
}
writeCLIFile(t, pipePath, strings.Replace(string(raw), "prompt_version: v-cli", "prompt_version: v-cli-drift", 1))
sel := pipeline.RedriveSelector{Chapter: -1, ChunkIdx: -1}
if err := redrive(ctx, bookPath, true, pipeline.RebillConsent{}, sel); err == nil {
t.Fatal("`redrive --resnapshot` without consent must be refused over the threshold")
} else if !strings.Contains(err.Error(), "--accept-rebill") {
t.Errorf("the redrive refusal must name the flag; got: %v", err)
}
if calls != callsAfterRun1 {
t.Fatalf("a refused redrive must reach no provider, calls=%d", calls)
}
// Consent given → the redrive resets and re-attacks (the mock still refuses, so it ends flagged again
// — what matters is that it RAN and billed).
if err := redrive(ctx, bookPath, true, pipeline.RebillConsent{Given: true}, sel); exitCode(err) != 2 {
t.Fatalf("`redrive --resnapshot --accept-rebill` must run (and end flagged), got %v", err)
}
if calls <= callsAfterRun1 {
t.Fatalf("the consented redrive must re-call the provider, calls=%d", calls)
}
}

View file

@ -98,6 +98,12 @@ type Book struct {
// snapshot. Resolved relative to book.yaml. Empty = no rejects.
MinedRejects string `yaml:"mined_rejects"`
Ceilings BookCap `yaml:"ceilings"`
// RebillConsentUSD overrides the ratified default threshold above which a run must be given
// explicit consent (`--accept-rebill[=usd]`) before it re-pays for work already billed —
// `min($0.50, 5% × ProjectedBookUSD)`, D20.2-Q2. 0 (absent) = the default formula. WIRING, not
// brief: it is deliberately absent from BriefHash and from the snapshot, so setting it never
// re-pays anything by itself.
RebillConsentUSD float64 `yaml:"rebill_consent_usd"`
}
// BookCap are the ledger admission limits (Р7: the $ ceiling per book/day).
@ -233,6 +239,9 @@ func LoadBook(path string) (*Book, error) {
if b.Ceilings.BookUSD <= 0 && b.Ceilings.DayUSD <= 0 {
bad("ceilings: at least one of book_usd/day_usd must be set (a ledger with no ceiling is forbidden, Р7)")
}
if b.RebillConsentUSD < 0 {
bad("rebill_consent_usd must be ≥ 0 (0 = the ratified default min($0.50, 5%%×projected book cost)), got %v", b.RebillConsentUSD)
}
if len(problems) > 0 {
return nil, fmt.Errorf("config %s:\n - %s", path, strings.Join(problems, "\n - "))
}

View file

@ -33,10 +33,13 @@ type StageResult struct {
FlagReason FlagReason // "" when ok
Detail string
Attempts int
// Escalated — a single-hop fallback draft was tried this stage (D12); when the
// Escalated — a single-hop fallback draft was ATTEMPTED this stage (D12); when the
// fallback passed the re-gate, Model above is the fallback (it answered).
Escalated bool
EscalationModel string // the fallback model when Escalated ("" otherwise)
Escalated bool
// EscalationModel is the fallback model whose output became AUTHORITATIVE ("" when no hop ran, and
// "" when the hop ran but also failed — then the primary's flag stands and nothing of the hop's
// output is used; `Escalated` alone carries the fact of the attempt). See stagerun.go.
EscalationModel string
// BankFlags carries the translator draft's banknote telemetry (WS4 point 10): accepted line count +
// parse-fail / truncation flags. Zero-valued on every non-translator stage and every channel-off run.
BankFlags bankFlags
@ -135,6 +138,15 @@ func (r *Runner) TranslateBook(ctx context.Context) (*BookResult, error) {
return nil, fmt.Errorf("pipeline: source file %s produced no chunks after normalization", r.Book.SourceFile)
}
// 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
// is over the book's threshold, it stops here with the sum instead of quietly re-buying the book.
// It needs the materialized memory (the per-wave snapshots fold it), so it sits after seedGlossary,
// and the chunk manifest (the projected book cost is per output unit), so it sits after the split.
if err := r.checkRebillConsent(ctx, chunks); err != nil {
return nil, err
}
// The run scale — one line to stderr (the smoke-run pain: N/M and the progress
// denominator never appeared in the logs at all, only in stdout/status).
r.Log.InfoContext(ctx, "book run started", "book", r.Book.BookID,

View file

@ -0,0 +1,235 @@
package pipeline
import (
"context"
"fmt"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/store"
)
// rebill.go: CONSENT TO A RE-PAYMENT (D20.2-Q2, spec §7.3 of backend/docs/D15.2-*.md) — the Р6
// "consent to a CONCRETE spend" gate standing in front of every path that would bill again for work
// this book has already been billed for.
//
// GRANULARITY (ratified with this pack). The spec's §7 projection is PER-CHUNK and rides `guard_hash`,
// which does not exist in this engine at all: drift here is BOOLEAN (a stored row's snapshot either is
// or is not the current one) and a snapshot move misses EVERY checkpoint of its wave. So consent is
// built at the granularity the engine actually re-pays at — the SNAPSHOT — and the projection is the
// honest reading of that: "the units already paid for under a superseded snapshot will be paid for
// again; the sum of their stored chunk_status.cost_usd is $X".
//
// Two consequences of that granularity, both deliberate:
// - the spec's §7.1-бис editor-cascade fix ("a stage strictly below a re-billed unit is itself
// re-billed") is DEGENERATE here — everything of the moved wave is re-billed already, so the
// cascade rule adds nothing. Its ratified ordering ("the cascade lands BEFORE the flag semantics")
// is therefore not violated but inapplicable;
// - the spec's EstimateUSD fallback ("for cascade units with no past price") has no subject either:
// every unit counted here HAS a stored price, because it is counted precisely for having been paid.
// A paid row whose stored cost is $0 is a genuinely $0 model (local/priced-zero), so 0 is its honest
// contribution rather than a gap to estimate around.
//
// WHAT IS NOT PROJECTED (documented, not silent): the CONTENT axis. A source edit that leaves the chunk
// manifest intact moves a chunk's content_hash but not the snapshot, so it re-bills exactly the touched
// chunks and is invisible here — the same caveat status.bookChunks carries, and the half that belongs
// to content-addressed resume (v3), not to this gate.
// Ratified default threshold (D20.2-Q2): `min($0.50, 5% × ProjectedBookUSD)`, with the $0.50 acting as
// an absolute FLOOR when the book has no processed units yet (the 5% branch would then be $0 and would
// demand consent for a one-cent append). A book may override it with `rebill_consent_usd`.
const (
rebillConsentFloorUSD = 0.50
rebillConsentShare = 0.05
)
// RebillConsent is the operator's answer to `--accept-rebill[=usd]`: the Р6 consent to a CONCRETE
// spend. A bare flag accepts whatever the projection turns out to be; `--accept-rebill=1.50` accepts it
// only while it stays at or below $1.50, so the consent names an amount rather than a blanket.
type RebillConsent struct {
Given bool // the flag was passed at all
Capped bool // a ceiling was named (--accept-rebill=<usd>)
CapUSD float64 // that ceiling; meaningful only when Capped
}
// RebillProjection is what a run would re-pay: the chunk×stage units resolved under a superseded
// snapshot, and the sum of what they cost the first time.
type RebillProjection struct {
Rows int
USD float64
}
// projectRebill sums the units that a run under the CURRENT config would pay for a second time.
//
// A unit counts when all three hold: it carries a snapshot (a row written by a real run), it was BILLED
// (ok/flagged — a `skipped` row never reached a provider and cost nothing), and its snapshot differs
// from what its own WAVE renders now. The per-wave comparison is load-bearing: a bank-mining enrichment
// moves only the edit-wave snapshot, and projecting the draft wave against it would report the whole
// book as re-billed when the drafts in fact resume at $0 (the "re-paid ONCE" invariant).
//
// Two kinds of stored row are ORPHANS — they exist but nothing will run them again, so counting them
// would ask the operator to consent to money that will not be spent:
// - a row whose STAGE the current pipeline no longer has (a renamed/retired stage);
// - a row whose CHUNK the current manifest no longer has (the source was shortened, so the position
// is gone). Both waves address their rows at manifest chunk positions — a draft row at its own
// chunk, an edit row at its unit's leader chunk — so one membership test covers both.
//
// Over-estimating is the safe direction for a consent gate, but a number the operator is asked to
// approve and then not charged is exactly what makes such a number stop being read.
//
// The wave snapshots are rendered LAZILY, so a draft-only pipeline never renders an edit-wave snapshot
// it has no stages for.
func (r *Runner) projectRebill(statuses []store.ChunkStatus, chunks []chunk.Chunk) (RebillProjection, error) {
var p RebillProjection
live := make(map[chunkKey]bool, len(chunks))
for _, ch := range chunks {
live[chunkKey{ch.Chapter, ch.ChunkIdx}] = true
}
draftNames := stageNameSet(r.waveStagesIndexed(waveDraft))
editNames := stageNameSet(r.waveStagesIndexed(waveEdit))
rendered := map[wave]string{}
current := func(w wave) (string, error) {
if s, ok := rendered[w]; ok {
return s, nil
}
s, _, err := r.snapshotIDForWave(w)
if err != nil {
return "", fmt.Errorf("pipeline: render the current snapshot for the re-bill projection: %w", err)
}
rendered[w] = s
return s, nil
}
for _, cs := range statuses {
if cs.SnapshotID == "" || cs.Disposition == string(DispSkipped) {
continue
}
if !live[chunkKey{cs.Chapter, cs.ChunkIdx}] {
continue // the position is gone from the manifest — nothing will re-run it
}
var w wave
switch {
case draftNames[cs.Stage]:
w = waveDraft
case editNames[cs.Stage]:
w = waveEdit
default:
continue // a stage the current pipeline does not run is never re-billed
}
cur, err := current(w)
if err != nil {
return p, err
}
if cs.SnapshotID == cur {
continue // resumes at $0
}
p.Rows++
p.USD += cs.CostUSD
}
return p, nil
}
// projectBookUSD extrapolates the book's total cost from the units already FULLY attempted (done or
// flagged), which is the base of the 5% consent threshold. It is the SINGLE definition of the number
// `tmctl status` reports as projected_book_usd — status used to compute it inline, and a threshold
// computed from a second, drifting definition of the same quantity is exactly the class of bug the
// memberDrops helper was extracted to remove.
//
// It deliberately does NOT use book-committed spend: committed also carries partial spend on
// IN-PROGRESS units, which are outside the denominator and would over-estimate the book.
func projectBookUSD(units []editUnit, byChunk map[chunkKey][]store.ChunkStatus, nDraftStages, nEditStages int) float64 {
var processedCost float64
processed := 0
for _, u := range units {
expected := len(u.Members)*nDraftStages + nEditStages
res := resolveChunkState(unitRows(u, byChunk), expected)
if res.State != ChunkDone && res.State != ChunkFlagged {
continue
}
processedCost += res.CostUSD
processed++
}
if processed == 0 {
return 0
}
return processedCost / float64(processed) * float64(len(units))
}
// rebillConsentThreshold resolves the consent threshold for this book: the book's own
// `rebill_consent_usd` when it declares one, else the ratified `min($0.50, 5% × ProjectedBookUSD)` with
// the $0.50 floor for a book that has processed nothing yet.
func (r *Runner) rebillConsentThreshold(projectedBookUSD float64) (usd float64, source string) {
if r.Book.RebillConsentUSD > 0 {
return r.Book.RebillConsentUSD, "book.rebill_consent_usd"
}
if projectedBookUSD <= 0 {
return rebillConsentFloorUSD, "the $0.50 floor — the book has no processed units to take 5% of"
}
if share := rebillConsentShare * projectedBookUSD; share < rebillConsentFloorUSD {
return share, fmt.Sprintf("5%% of the projected book cost $%.6f", projectedBookUSD)
}
return rebillConsentFloorUSD, fmt.Sprintf("the $0.50 cap, under 5%% of the projected book cost $%.6f", projectedBookUSD)
}
// checkRebillConsent is the gate: it refuses BEFORE any reservation when the run would re-pay more than
// the book's consent threshold and the operator has not consented to that amount.
//
// It is called from both write paths — TranslateBook (before the waves, hence before the first
// Reserve) and Redrive (before the DESTRUCTIVE reset, so a refusal cannot leave the flag telemetry
// deleted, the external-review 1c torn-state discipline). The $0 read-only surfaces (status / report /
// export, D20.4) never reach it, so a book that needs consent stays fully inspectable.
//
// It is deliberately NOT conditioned on r.Resnapshot. --resnapshot is the permission to RE-PIN, and a
// permission that names no amount cannot carry a Р6 consent to a concrete spend — that is precisely
// the debt this closes. It also covers the case --resnapshot does not: a run interrupted midway through
// a re-pin leaves jobs on the new snapshot while their chunk_status rows still carry the old one, and
// the next plain `translate` then re-bills them with no gate at all.
func (r *Runner) checkRebillConsent(ctx context.Context, chunks []chunk.Chunk) error {
statuses, err := r.Store.ChunkStatusesForBook(r.Book.BookID)
if err != nil {
return fmt.Errorf("pipeline: read chunk_status for the re-bill projection: %w", err)
}
proj, err := r.projectRebill(statuses, chunks)
if err != nil {
return err
}
if proj.Rows == 0 {
return nil // nothing already-paid is superseded — this run bills only new work
}
byChunk := map[chunkKey][]store.ChunkStatus{}
for _, cs := range statuses {
byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}] = append(byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}], cs)
}
bookUSD := projectBookUSD(r.outputUnits(chunks), byChunk,
len(r.waveStagesIndexed(waveDraft)), len(r.waveStagesIndexed(waveEdit)))
threshold, source := r.rebillConsentThreshold(bookUSD)
// A NAMED ceiling is an instruction, not merely a consent form: it is honoured even below the
// threshold, so an operator who wrote "no more than $X" is never billed $X+ε on the grounds that the
// amount was small enough not to need asking.
if r.AcceptRebill.Capped && proj.USD > r.AcceptRebill.CapUSD {
return fmt.Errorf("pipeline: the projected re-payment is ~$%.6f (%d chunk×stage unit(s) already billed under a superseded snapshot) but --accept-rebill=%g caps consent at $%.6f — refusing. NOTHING was reserved and no row was touched. Raise the ceiling, or pass a bare --accept-rebill to accept the full projected amount",
proj.USD, proj.Rows, r.AcceptRebill.CapUSD, r.AcceptRebill.CapUSD)
}
if r.AcceptRebill.Given {
r.Log.WarnContext(ctx, "accepting a projected re-payment of already-billed work (--accept-rebill)",
"rebill_units", proj.Rows, "rebill_usd", fmt.Sprintf("%.6f", proj.USD),
"threshold_usd", fmt.Sprintf("%.6f", threshold))
return nil
}
if proj.USD <= threshold {
// Under the threshold the run continues without friction (the ratified behaviour: a term append
// touching three chunks costs cents). Continuing is not the same as being silent — the amount is
// money and it goes to the log.
r.Log.InfoContext(ctx, "re-paying already-billed work under the consent threshold; continuing without asking",
"rebill_units", proj.Rows, "rebill_usd", fmt.Sprintf("%.6f", proj.USD),
"threshold_usd", fmt.Sprintf("%.6f", threshold))
return nil
}
hint := ""
if !r.Resnapshot {
hint = " The run also needs --resnapshot: without it the superseded jobs stop it anyway."
}
return fmt.Errorf("pipeline: this run would RE-PAY for work already billed: %d chunk×stage unit(s) are resolved under a superseded snapshot and would be paid for again, ~$%.6f (the sum of their stored cost_usd). That is over this book's consent threshold $%.6f (%s), and Р6 requires consent to a CONCRETE spend, not a blanket one (D20.2-Q2). NOTHING was reserved and no row was touched. Re-run with --accept-rebill to accept the whole projected amount, or --accept-rebill=<usd> to accept it only up to a ceiling (a ceiling below the projection refuses).%s",
proj.Rows, proj.USD, threshold, source, hint)
}

View file

@ -0,0 +1,660 @@
package pipeline
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/store"
)
// rebill_test.go: the consent-to-a-re-payment gate (D20.2-Q2, rebill.go). The load-bearing property is
// NEGATIVE — that a run which would re-pay for already-billed work stops BEFORE any reservation — so
// every scenario asserts the money state (committed unchanged, reserved 0), the provider-call count and
// the durable rows, not just the error string.
// driftPipelineVersion bumps the prompt_version of EVERY stage in the fixture pipeline, so BOTH wave
// snapshots move — the "the whole book would be re-paid" case the threshold exists for.
func driftPipelineVersion(t *testing.T, bookPath string) {
t.Helper()
path := filepath.Join(filepath.Dir(bookPath), "pipeline.yaml")
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
changed := strings.ReplaceAll(string(raw), "prompt_version: v-test", "prompt_version: v-test-drift")
if changed == string(raw) {
t.Fatal("setup: prompt_version token not found in the fixture pipeline")
}
writeFile(t, path, changed)
}
// chunksOf is the book's current chunk manifest — the "which positions still exist" input the
// projection needs so an orphaned row is not billed to the operator's consent.
func chunksOf(t *testing.T, r *Runner) []chunk.Chunk {
t.Helper()
chunks, err := r.bookChunks()
if err != nil {
t.Fatal(err)
}
return chunks
}
// moneyState is the book's ledger + durable resume state, for before/after comparison across a refusal.
type moneyState struct {
committed, reserved float64
draftSnap, editSnap string
calls int
}
func readMoneyState(t *testing.T, r *Runner, rec *reqRec) moneyState {
t.Helper()
committed, reserved, err := r.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
m := moneyState{committed: committed, reserved: reserved, calls: rec.count()}
if cs, err := r.Store.GetChunkStatus("test-book", 1, 0, "draft"); err == nil && cs != nil {
m.draftSnap = cs.SnapshotID
}
if cs, err := r.Store.GetChunkStatus("test-book", 1, 0, "edit"); err == nil && cs != nil {
m.editSnap = cs.SnapshotID
}
return m
}
// TestRebillConsentRefusesOverThresholdBeforeAnyMoney is the pack's headline invariant: --resnapshot
// alone no longer re-buys a book silently. The run stops with the SUM and the unit count, having
// reserved nothing, called nothing and rewritten no durable row.
func TestRebillConsentRefusesOverThresholdBeforeAnyMoney(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
before := readMoneyState(t, r1, rec)
r1.Close()
if before.committed <= 0 || before.calls != 2 {
t.Fatalf("setup: run 1 must bill 2 calls, got committed=%v calls=%d", before.committed, before.calls)
}
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true // permission to RE-PIN — deliberately not consent to the AMOUNT
_, err := r2.TranslateBook(ctx)
if err == nil {
t.Fatal("a re-payment over the consent threshold must refuse, not proceed on --resnapshot alone")
}
msg := err.Error()
for _, want := range []string{"RE-PAY", "chunk×stage unit(s)", "--accept-rebill", "consent threshold"} {
if !strings.Contains(msg, want) {
t.Errorf("the refusal must name %q so the operator sees what to do; got: %s", want, msg)
}
}
// The projected sum itself must be IN the message (both rows of the fixture, 2×fakeCallUSD).
if !strings.Contains(msg, "0.003640") {
t.Errorf("the refusal must carry the projected amount (~$%.6f); got: %s", 2*fakeCallUSD, msg)
}
after := readMoneyState(t, r2, rec)
if after.calls != before.calls {
t.Errorf("a refused run must reach no provider: calls %d → %d", before.calls, after.calls)
}
if after.committed != before.committed || after.reserved != 0 {
t.Errorf("a refused run must move no money: committed %v → %v, reserved=%v", before.committed, after.committed, after.reserved)
}
if after.draftSnap != before.draftSnap || after.editSnap != before.editSnap {
t.Errorf("a refused run must not re-pin any durable row: draft %.12s → %.12s, edit %.12s → %.12s",
before.draftSnap, after.draftSnap, before.editSnap, after.editSnap)
}
}
// TestRebillConsentGivenProceeds is the positive half: with the consent the same run goes through and
// really does re-pay (so the gate is a gate, not a permanent stop).
func TestRebillConsentGivenProceeds(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
callsAfterRun1 := rec.count()
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true
r2.AcceptRebill = RebillConsent{Given: true}
res, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatalf("consent given → the run must proceed: %v", err)
}
if rec.count() <= callsAfterRun1 {
t.Fatalf("the consented run must actually re-call the provider: calls=%d (after run1=%d)", rec.count(), callsAfterRun1)
}
if res.TotalUSD <= 0 {
t.Fatal("the consented re-translation must be billed")
}
}
// TestRebillConsentCapBelowProjectionRefuses pins the Р6 half that makes the consent CONCRETE: a named
// ceiling below the projection refuses, and the same ceiling above it proceeds. Without the cap branch
// `--accept-rebill=0.001` would be a blanket consent under a number.
func TestRebillConsentCapBelowProjectionRefuses(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
callsAfterRun1 := rec.count()
driftPipelineVersion(t, bookPath)
// Ceiling BELOW the projected $0.00364 → refuse.
r2 := newRunner(t, bookPath)
r2.Resnapshot = true
r2.AcceptRebill = RebillConsent{Given: true, Capped: true, CapUSD: 0.001}
_, err := r2.TranslateBook(ctx)
r2.Close()
if err == nil || !strings.Contains(err.Error(), "caps consent at") {
t.Fatalf("a ceiling below the projection must refuse naming the cap, got: %v", err)
}
if rec.count() != callsAfterRun1 {
t.Fatalf("a cap-refused run must reach no provider: calls=%d", rec.count())
}
// Ceiling ABOVE it → proceed.
r3 := newRunner(t, bookPath)
defer r3.Close()
r3.Resnapshot = true
r3.AcceptRebill = RebillConsent{Given: true, Capped: true, CapUSD: 0.01}
if _, err := r3.TranslateBook(ctx); err != nil {
t.Fatalf("a ceiling above the projection must proceed: %v", err)
}
if rec.count() <= callsAfterRun1 {
t.Fatalf("the capped-but-sufficient consent must re-call the provider: calls=%d", rec.count())
}
}
// TestRebillUnderThresholdProceedsUnasked pins the ratified "below the threshold — automatically"
// behaviour, through the book's own `rebill_consent_usd` override (a cent-scale append must not cost
// the operator a round-trip). It also pins the override itself, which is otherwise unreachable.
func TestRebillUnderThresholdProceedsUnasked(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{regenerate: 1, rebillConsentUSD: 1.0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if r1.Book.RebillConsentUSD != 1.0 {
t.Fatalf("setup: the book override did not load, got %v", r1.Book.RebillConsentUSD)
}
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
callsAfterRun1 := rec.count()
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true // NO --accept-rebill: the projection ($0.00364) is under the book's $1.00 threshold
if _, err := r2.TranslateBook(ctx); err != nil {
t.Fatalf("a re-payment under the threshold must proceed unasked: %v", err)
}
if rec.count() <= callsAfterRun1 {
t.Fatalf("the under-threshold run must still do the work: calls=%d", rec.count())
}
}
// TestRebillConsentThresholdFormula is the ratified arithmetic, in isolation: min($0.50, 5%×projected),
// the $0.50 FLOOR for a book with nothing processed yet (the 5% branch would be $0 and would demand
// consent for a cent), and the book override winning over both.
func TestRebillConsentThresholdFormula(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
r := newRunner(t, setupProject(t, srv.URL))
defer r.Close()
for _, tc := range []struct {
name string
projected float64
override float64
want float64
}{
{"floor when nothing processed", 0, 0, 0.50},
{"5% of a cheap book", 4.0, 0, 0.20},
{"capped at $0.50 on an expensive book", 100.0, 0, 0.50},
{"exactly at the $10 crossover", 10.0, 0, 0.50},
{"book override wins", 100.0, 2.5, 2.5},
} {
r.Book.RebillConsentUSD = tc.override
got, _ := r.rebillConsentThreshold(tc.projected)
if got != tc.want {
t.Errorf("%s: threshold(projected=%v, override=%v) = %v, want %v", tc.name, tc.projected, tc.override, got, tc.want)
}
}
}
// TestRebillProjectionIsPerWave pins the money keystone the wave split bought: a mined sign moves ONLY
// the edit-wave snapshot, so the projection must count ONE row (the edit), never the draft. A whole-
// pipeline comparison would report the paid draft wave as re-billed and ask the owner to consent to
// double the real amount — the "re-paid ONCE" invariant, seen from the consent side.
func TestRebillProjectionIsPerWave(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
src := "方源走进了魔法学院的图书馆。"
seed := "terms:\n - src: 魔法学院\n dst: Академия магии\n status: approved\n"
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: src, glossarySeed: seed, regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
editBefore, err := r1.Store.GetChunkStatus("test-book", 1, 0, "edit")
if err != nil || editBefore == nil {
t.Fatalf("setup: edit row after run 1: %v / %v", editBefore, err)
}
// Baseline, and the OTHER direction of the per-wave rule: with both waves resolved under their own
// current snapshots nothing is superseded. Comparing an edit row against the draft wave's snapshot
// (or vice versa) would report a re-bill on a book that has not drifted at all.
statusesBefore, err := r1.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
if proj, err := r1.projectRebill(statusesBefore, chunksOf(t, r1)); err != nil || proj.Rows != 0 {
t.Fatalf("a freshly-run book projects no re-bill; each row must be judged against ITS OWN wave, got %+v (err=%v)", proj, err)
}
r1.Close()
dir := filepath.Dir(bookPath)
writeFile(t, filepath.Join(dir, "mined-delta.yaml"), "terms:\n - src: 方源\n dst: Фан Юань\n status: approved\n")
rawBook, err := os.ReadFile(bookPath)
if err != nil {
t.Fatal(err)
}
writeFile(t, bookPath, strings.Replace(string(rawBook), "pipeline: pipeline.yaml", "pipeline: pipeline.yaml\nmined_delta: mined-delta.yaml", 1))
r2 := newRunner(t, bookPath)
defer r2.Close()
if err := r2.seedGlossary(ctx); err != nil { // materializes the banks the wave snapshots fold
t.Fatal(err)
}
statuses, err := r2.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
proj, err := r2.projectRebill(statuses, chunksOf(t, r2))
if err != nil {
t.Fatal(err)
}
if proj.Rows != 1 {
t.Fatalf("a mined sign re-bills the EDIT wave only — projection must count 1 row, got %d ($%.6f)", proj.Rows, proj.USD)
}
if proj.USD != editBefore.CostUSD {
t.Fatalf("the projected amount must be the edit row's stored cost $%.6f, got $%.6f", editBefore.CostUSD, proj.USD)
}
}
// TestRebillProjectionExcludesSkippedAndUnchanged: a `skipped` row never reached a provider and cost
// nothing, so re-running it is NEW work, not a re-payment — counting it would inflate the number the
// operator consents to. And with no drift at all the projection is empty, so an ordinary $0 resume is
// never asked anything.
func TestRebillProjectionExcludesSkippedAndUnchanged(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
return "Извините, я не могу перевести это.", "stop"
}
return draftEdit(body)
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
statuses, err := r1.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
var skipped int
for _, cs := range statuses {
if cs.Disposition == string(DispSkipped) {
skipped++
}
}
if skipped == 0 {
t.Fatal("setup: the refusal fixture must leave a skipped edit row")
}
// No drift yet: nothing is superseded, so nothing is projected.
if proj, err := r1.projectRebill(statuses, chunksOf(t, r1)); err != nil || proj.Rows != 0 || proj.USD != 0 {
t.Fatalf("an undrifted book must project no re-bill, got %+v (err=%v)", proj, err)
}
r1.Close()
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
if err := r2.seedGlossary(ctx); err != nil {
t.Fatal(err)
}
proj, err := r2.projectRebill(statuses, chunksOf(t, r2))
if err != nil {
t.Fatal(err)
}
if proj.Rows != 1 {
t.Fatalf("only the BILLED draft row may be projected (the skipped edit costs nothing to redo), got %d rows", proj.Rows)
}
if proj.USD != fakeCallUSD {
t.Fatalf("projected amount = the draft row's stored cost $%.6f, got $%.6f", fakeCallUSD, proj.USD)
}
}
// TestRebillProjectionIgnoresRetiredStages: a row left behind by a stage the pipeline no longer runs
// can never be re-billed, because nothing will call it. Counting it would inflate the amount the
// operator is asked to consent to (and, on a book with a long stage history, arbitrarily so).
func TestRebillProjectionIgnoresRetiredStages(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
// A leftover row of a stage this pipeline does not have, under a snapshot that is nobody's current one.
if err := r.Store.UpsertChunkStatus(store.ChunkStatus{
BookID: "test-book", Chapter: 1, ChunkIdx: 0, Stage: "annotate",
SnapshotID: strings.Repeat("a", 64), ContentHash: "x", Disposition: string(DispOK), CostUSD: 9.99,
}); err != nil {
t.Fatal(err)
}
statuses, err := r.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
proj, err := r.projectRebill(statuses, chunksOf(t, r))
if err != nil {
t.Fatal(err)
}
if proj.Rows != 0 || proj.USD != 0 {
t.Fatalf("a retired stage's row is not re-billable and must not be projected, got %+v", proj)
}
}
// TestRebillProjectionIgnoresVanishedChunks: the other orphan class. If the source is shortened, the
// rows of the removed positions stay in the store but nothing will re-run them, so they must not be
// billed to the operator's consent. Over-asking is the safe direction, but a number that is asked for
// and then not spent is how such a number stops being read.
func TestRebillProjectionIgnoresVanishedChunks(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ГЛАВАПЕРВАЯ\fГЛАВАВТОРАЯ", regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
statuses, err := r1.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
if len(statuses) != 4 { // 2 chapters × (draft + edit)
t.Fatalf("setup: want 4 stored rows over 2 chapters, got %d", len(statuses))
}
r1.Close()
// Chapter 2 disappears from the source, and the config drifts so everything surviving is superseded.
writeFile(t, filepath.Join(filepath.Dir(bookPath), "source.txt"), "ГЛАВАПЕРВАЯ")
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
if err := r2.seedGlossary(ctx); err != nil {
t.Fatal(err)
}
proj, err := r2.projectRebill(statuses, chunksOf(t, r2))
if err != nil {
t.Fatal(err)
}
if proj.Rows != 2 {
t.Fatalf("only the SURVIVING chapter's 2 rows are re-billable (chapter 2 is gone from the manifest), got %d rows / $%.6f", proj.Rows, proj.USD)
}
}
// TestRebillConsentKeepsReadOnlyPathsAlive is the D20.4 $0 contract: a book whose next translate would
// be refused for want of consent must stay fully inspectable — status/report/export make no money
// decision, so the gate must not be anywhere near them.
func TestRebillConsentKeepsReadOnlyPathsAlive(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
driftPipelineVersion(t, bookPath)
// The write path refuses…
rw := newRunner(t, bookPath)
rw.Resnapshot = true
_, werr := rw.TranslateBook(ctx)
rw.Close()
if werr == nil {
t.Fatal("setup: the write path must be refused for this to mean anything")
}
// …and every read-only surface still answers, at $0.
ro, err := NewReadOnlyRunner(bookPath, obs.NewLogger())
if err != nil {
t.Fatalf("a book awaiting re-bill consent must still open read-only: %v", err)
}
defer ro.Close()
callsBefore := rec.count()
rep, err := ro.Status(ctx)
if err != nil {
t.Fatalf("status must stay alive: %v", err)
}
if !rep.ConfigDrift {
t.Error("status should report the drift that the consent gate is refusing over")
}
if _, err := ro.Export(false); err != nil {
t.Fatalf("export must stay alive: %v", err)
}
if _, err := ro.QualityReport(); err != nil {
t.Fatalf("report must stay alive: %v", err)
}
if rec.count() != callsBefore {
t.Fatalf("the read-only surfaces must stay $0: calls %d → %d", callsBefore, rec.count())
}
}
// TestRedriveRefusesRebillBeforeDestructiveReset is the external-review 1c discipline applied to the new
// gate: a redrive that would re-pay over the threshold must refuse BEFORE ResetChunkStages, so the flag
// telemetry it was about to re-attack still exists afterwards. Moving the check below the reset loop
// leaves this test with a deleted row.
func TestRedriveRefusesRebillBeforeDestructiveReset(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
return "Извините, я не могу перевести это.", "stop"
}
return draftEdit(body)
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
callsAfterRun1 := rec.count()
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true // skips the drift COMPARISON — but not the question of what the re-pin costs
sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
if err == nil {
t.Fatal("redrive --resnapshot over the consent threshold must refuse")
}
if !strings.Contains(err.Error(), "--accept-rebill") {
t.Errorf("the redrive refusal must name the flag; got: %v", err)
}
if res != nil || sum.ResetRun {
t.Errorf("a consent-refused redrive must not re-run: res=%v resetRun=%v", res, sum.ResetRun)
}
if cs, _ := r2.Store.GetChunkStatus("test-book", 1, 0, "draft"); cs == nil || cs.Disposition != string(DispFlagged) {
t.Fatalf("the flagged row must survive a consent-refused redrive (1c torn-state class), got: %+v", cs)
}
if rec.count() != callsAfterRun1 {
t.Fatalf("a consent-refused redrive must reach no provider: calls=%d", rec.count())
}
}
// TestRedriveDryRunNeedsNoConsent: --dry-run reports the plan and touches nothing, so it is $0 by
// construction and must never be gated on a spend consent.
func TestRedriveDryRunNeedsNoConsent(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if strings.Contains(body, "ОТКАЗ") && !isEditBody(body) {
return "Извините, я не могу перевести это.", "stop"
}
return draftEdit(body)
})
defer srv.Close()
bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: "ОТКАЗ", regenerate: 0})
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r1 := newRunner(t, bookPath)
if _, err := r1.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
r1.Close()
driftPipelineVersion(t, bookPath)
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true
sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1, DryRun: true})
if err != nil {
t.Fatalf("a dry-run redrive spends nothing and must not need consent: %v", err)
}
if res != nil || sum.ResetRun || len(sum.Targets) == 0 {
t.Fatalf("dry-run must report a plan without running: %+v (res=%v)", sum, res)
}
}
// TestProjectBookUSDExtrapolates pins the EXTRAPOLATION itself — the part a fully-processed fixture
// cannot see, because there processed == total and every wrong denominator gives the right answer. The
// threshold is 5% of this number, so an average taken over the wrong denominator would silently move
// the point at which the operator is asked for consent.
func TestProjectBookUSDExtrapolates(t *testing.T) {
units := []editUnit{
{Chapter: 1, FirstChunkIdx: 0, Members: []chunk.Chunk{{Chapter: 1, ChunkIdx: 0}}},
{Chapter: 2, FirstChunkIdx: 0, Members: []chunk.Chunk{{Chapter: 2, ChunkIdx: 0}}},
{Chapter: 3, FirstChunkIdx: 0, Members: []chunk.Chunk{{Chapter: 3, ChunkIdx: 0}}},
}
// Only chapter 1 has been attempted: draft + edit resolved ok at $1.00 each. Chapters 2-3 are pending.
byChunk := map[chunkKey][]store.ChunkStatus{
{chapter: 1, chunkIdx: 0}: {
{Stage: "draft", Disposition: string(DispOK), CostUSD: 1.0},
{Stage: "edit", Disposition: string(DispOK), CostUSD: 1.0},
},
}
// $2.00 over 1 processed unit × 3 units in the book = $6.00.
if got := projectBookUSD(units, byChunk, 1, 1); got != 6.0 {
t.Fatalf("projected book cost = %v, want 6.0 ($2.00/processed unit × 3 units)", got)
}
// Nothing processed → no basis to extrapolate from → $0 (which is what makes the threshold fall back
// to its $0.50 floor rather than to 5%×0 = $0).
if got := projectBookUSD(units, map[chunkKey][]store.ChunkStatus{}, 1, 1); got != 0 {
t.Fatalf("with nothing processed the projection is 0, got %v", got)
}
}
// TestProjectBookUSDMatchesStatus locks the single-definition extraction: the threshold's base and the
// number `tmctl status` publishes are computed by the SAME function, so they cannot drift apart.
func TestProjectBookUSDMatchesStatus(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := setupProject(t, srv.URL)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
rep, err := r.Status(ctx)
if err != nil {
t.Fatal(err)
}
chunks, err := r.bookChunks()
if err != nil {
t.Fatal(err)
}
statuses, err := r.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
byChunk := map[chunkKey][]store.ChunkStatus{}
for _, cs := range statuses {
byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}] = append(byChunk[chunkKey{cs.Chapter, cs.ChunkIdx}], cs)
}
got := projectBookUSD(r.outputUnits(chunks), byChunk,
len(r.waveStagesIndexed(waveDraft)), len(r.waveStagesIndexed(waveEdit)))
if got != rep.ProjectedBookUSD {
t.Fatalf("projectBookUSD = %v but status reports %v — the threshold base and the published projection diverged", got, rep.ProjectedBookUSD)
}
if got != 2*fakeCallUSD {
t.Fatalf("the one-unit fixture projects its own cost: got %v, want %v", got, 2*fakeCallUSD)
}
}

View file

@ -48,6 +48,11 @@ type Runner struct {
// a divergence is a loud error: a context change invalidates the checkpoints and
// re-pays for the calls, so it is done only deliberately (Р6).
Resnapshot bool
// AcceptRebill is the operator's consent to the projected RE-PAYMENT of already-billed work
// (tmctl --accept-rebill[=usd], D20.2-Q2). It is ORTHOGONAL to Resnapshot: that flag grants
// permission to re-pin, this one consents to an AMOUNT (rebill.go). Zero value = no consent, which
// only matters when the projection exceeds the book's threshold.
AcceptRebill RebillConsent
clients map[string]llm.LLMClient
templates map[string]*PromptTemplate

View file

@ -297,10 +297,12 @@ func TestRunnerMemoryResnapshotOnApprovedChange(t *testing.T) {
t.Fatalf("denied resume must not call the provider: calls=%d (after run1=%d)", rec.count(), callsAfterRun1)
}
// With --resnapshot the book re-translates under the new approved glossary.
// With --resnapshot the book re-translates under the new approved glossary — plus the Р6 consent to
// what that re-translation costs (--accept-rebill, D20.2-Q2, rebill.go).
r3 := newRunner(t, bookPath)
defer r3.Close()
r3.Resnapshot = true
r3.AcceptRebill = RebillConsent{Given: true}
if _, err := r3.TranslateBook(ctx); err != nil {
t.Fatal(err)
}

View file

@ -114,6 +114,9 @@ type projectOpts struct {
postcheckGate bool // enable the memory post-check hard gate (assumes gatesYAML is empty)
banknote bool // enable the banknote channel gate (WS4; assumes gatesYAML/postcheckGate empty)
waveWorkers int // wave-executor parallelism (0 → default 1, a deterministic sequential-structured run)
// rebillConsentUSD writes book.yaml's `rebill_consent_usd` override (D20.2-Q2). 0 = omit the key, so
// the book takes the ratified default threshold and every pre-existing fixture is byte-unchanged.
rebillConsentUSD float64
}
func setupProjectOpts(t *testing.T, providerURL string, o projectOpts) string {
@ -179,6 +182,9 @@ stages:
writeFile(t, filepath.Join(dir, "glossary-seed.yaml"), o.glossarySeed)
glossaryLine = "glossary_seed: glossary-seed.yaml"
}
if o.rebillConsentUSD > 0 {
glossaryLine += fmt.Sprintf("\nrebill_consent_usd: %g", o.rebillConsentUSD)
}
writeFile(t, filepath.Join(dir, "book.yaml"), fmt.Sprintf(`
book_id: test-book
title: Тест
@ -374,6 +380,9 @@ func TestRunnerSnapshotPinning(t *testing.T) {
r3 := newRunner(t, bookPath)
defer r3.Close()
r3.Resnapshot = true
// --resnapshot permits the re-pin; the Р6 consent to the AMOUNT it re-pays is the separate
// --accept-rebill (D20.2-Q2, rebill.go) — without it this run now stops with the sum.
r3.AcceptRebill = RebillConsent{Given: true}
res, err := r3.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
@ -1262,11 +1271,73 @@ func TestRunnerEscalationFallbackAlsoFailsFlags(t *testing.T) {
if !ch.Stages[0].Escalated {
t.Fatalf("the draft must record that escalation was tried, got %+v", ch.Stages[0])
}
// pack-18 (D39.26 §14.6.1): escalation_model names the model whose output became AUTHORITATIVE.
// This hop REFUSED, so nothing of its output is used and the field is empty — the fact that a hop
// was tried and billed is `Escalated`, and the hop's identity lives in its own checkpoint /
// request_log row. Writing the tried model here made a refused hop read as the answering one.
if ch.Stages[0].EscalationModel != "" {
t.Fatalf("a hop that ALSO failed is not authoritative — escalation_model must stay empty, got %q", ch.Stages[0].EscalationModel)
}
if cs, err := r.Store.GetChunkStatus("test-book", 1, 0, "draft"); err != nil || cs == nil {
t.Fatalf("draft chunk_status: %v / %v", cs, err)
} else if !cs.Escalated || cs.EscalationModel != "" {
t.Fatalf("the DURABLE row must carry escalated=true with an empty escalation_model, got escalated=%v model=%q", cs.Escalated, cs.EscalationModel)
}
// The hop is not forgotten: its own billed request_log row still names the fallback model.
rows, err := r.Store.RequestLogRows("test-book")
if err != nil {
t.Fatal(err)
}
hopRows := 0
for _, rl := range rows {
if rl.ModelRequested == "fake-fallback" {
hopRows++
}
}
if hopRows != 1 {
t.Fatalf("the refused hop's identity must survive in the request log (1 row for fake-fallback), got %d", hopRows)
}
if ch.Stages[1].Disposition != DispSkipped {
t.Fatalf("edit must be skipped after a failed escalation, got %+v", ch.Stages[1])
}
}
// TestEscalationModelSurvivesResumeAsAuthoritative pins the OTHER half of the same semantics on the
// $0 resume path: a hop whose output DID ship keeps naming the fallback across a restart (the value is
// read back from the durable row, not re-derived), and a hop that failed keeps its empty field. Without
// both halves the column would mean one thing on a fresh run and another on a resumed one.
func TestEscalationModelSurvivesResumeAsAuthoritative(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, echoOrClean) // primary echoes, fallback answers cleanly
defer srv.Close()
bookPath := setupEscalationProject(t, srv.URL, 1.0, nil)
ctx := context.Background()
r1 := newRunner(t, bookPath)
res1, err := r1.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if d := res1.Chunks[0].Stages[0]; !d.Escalated || d.EscalationModel != "fake-fallback" {
t.Fatalf("an ANSWERING hop is authoritative and must be named: escalated=%v model=%q", d.Escalated, d.EscalationModel)
}
callsAfterRun1 := rec.count()
r1.Close()
r2 := newRunner(t, bookPath)
defer r2.Close()
res2, err := r2.TranslateBook(ctx)
if err != nil {
t.Fatal(err)
}
if rec.count() != callsAfterRun1 {
t.Fatalf("the resume must be $0: calls %d → %d", callsAfterRun1, rec.count())
}
if d := res2.Chunks[0].Stages[0]; !d.Escalated || d.EscalationModel != "fake-fallback" {
t.Fatalf("the resumed row must report the SAME authoritative model: escalated=%v model=%q", d.Escalated, d.EscalationModel)
}
}
// escalation.budget_usd = 0 disables escalation (opt-in): the deterministic flag
// stays a flag, no fallback call is made.
func TestRunnerEscalationBudgetZeroDisables(t *testing.T) {

View file

@ -155,6 +155,20 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
// Single-hop escalation (D12; the full policy lives in escalation.go). The hop
// runs at most ONCE, is re-gated, and never resets the retry budget; a ceiling-
// denied hop keeps the primary flag instead of aborting the book.
//
// The two facts are recorded SEPARATELY and mean different things (pack-18, from the pack-17
// verdict-change, D39.26 §14.6.1):
// - `escalated` = a hop was ATTEMPTED and billed. It is the money/telemetry fact, and it is true
// whatever the hop answered;
// - `escModel` = the model whose output became AUTHORITATIVE — the one whose bytes this stage
// ships. When the hop also failed, the primary's flag stands, nothing of the hop's output is
// used, and the field is EMPTY: "we tried X" is already carried by `escalated`, and writing X
// into a column named "the model of this result" made a REFUSED hop look like the answering one
// (the previous golden pinned exactly that on ch3).
// The hop's identity is not lost by this: the attempt has its own durable checkpoint (its own
// request_hash on the hop model) and its own request_log row, which is where the path of hops is
// read from — the pack-16 precedent of deriving counters from durable rows instead of migrating a
// column.
escalated, escModel := false, ""
esc, err := r.maybeEscalate(ctx, st, snapID, ch, job, baseMaxTokens, msgs, last, isFinal)
if err != nil {
@ -164,16 +178,17 @@ func (r *Runner) runStage(ctx context.Context, st config.Stage, stageIdx int, sn
cumCost += esc.fb.cumCost
runCost += esc.fb.runCost
anyFresh = anyFresh || esc.fb.freshCall
escalated, escModel = true, st.ResolvedHop
escalated = true
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
escModel = st.ResolvedHop
}
// else: the fallback also failed → keep the primary flag (last unchanged);
// the fallback call is billed and counted, the chunk stays flagged (1 hop).
// else: the fallback also failed → keep the primary flag (last unchanged) and leave escModel
// empty; the fallback call is billed and counted, the chunk stays flagged (1 hop).
}
disposition := last.cls.Reason.disposition()

View file

@ -328,7 +328,6 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
rep := &StatusReport{BookID: r.Book.BookID, TotalUnits: len(units)}
passports := map[int]*ChapterPassport{}
var chapterOrder []int
var processedCost float64 // spend of PROCESSED units (done+flagged) — the projection base (finding #7)
for _, u := range units {
p := passports[u.Chapter]
@ -386,9 +385,6 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
rep.Pending++
p.UnitsPending++
}
if state == ChunkDone || state == ChunkFlagged {
processedCost += res.CostUSD // a fully-attempted unit's cost feeds the projection
}
}
// Chapter verdicts (exp07 chapter rule: 0 flagged = pass, 1 = attention, ≥2 = fail — over UNITS: a
@ -476,14 +472,13 @@ func (r *Runner) Status(ctx context.Context) (*StatusReport, error) {
if rep.BookCeilingUSD > 0 {
rep.CeilingPct = 100 * (committed + reserved) / rep.BookCeilingUSD
}
// Projected book cost: extrapolate the per-PROCESSED-chunk average over the whole book
// (done + flagged = a chunk fully attempted). Uses processedCost, NOT book committed:
// committed also carries partial spend on IN-PROGRESS chunks (excluded from the denominator),
// which would over-estimate (finding #7). The clean average × total is the honest estimate.
// Projected book cost: extrapolate the per-PROCESSED-unit average over the whole book (done +
// flagged = a unit fully attempted). NOT book committed: committed also carries partial spend on
// IN-PROGRESS units (excluded from the denominator), which would over-estimate (finding #7). The
// arithmetic lives in projectBookUSD (rebill.go) because the consent threshold is 5% of THIS number
// — one definition, so the threshold can never be computed from a drifted copy of it.
processed := rep.Done + rep.Flagged
if processed > 0 {
rep.ProjectedBookUSD = processedCost / float64(processed) * float64(rep.TotalUnits)
}
rep.ProjectedBookUSD = projectBookUSD(units, byChunk, len(draftStages), len(editStages))
// ETA (secondary): mean fresh-call throughput × remaining processing. No synthetic bar.
// DEVIATION from D12 (which ratified an EWMA) — minor 1d, made explicit: this is a plain
@ -679,6 +674,19 @@ func (r *Runner) Redrive(ctx context.Context, sel RedriveSelector) (*RedriveSumm
}
}
// Consent to a RE-PAYMENT (D20.2-Q2, rebill.go) — BEFORE the destructive reset, for the same reason
// the drift guard is: a refusal must not leave the flag telemetry deleted (external-review 1c). It
// runs on BOTH paths, --resnapshot included: that flag skips the snapshot COMPARISON above, never
// the question of what the re-pin costs. The targets' own re-attack is not what this projects — that
// spend is the command's declared purpose — but the book-wide re-payment a snapshot move brings is.
rebillChunks, err := r.bookChunks()
if err != nil {
return summary, nil, fmt.Errorf("pipeline: redrive re-bill projection: %w", err)
}
if err := r.checkRebillConsent(ctx, rebillChunks); err != nil {
return summary, nil, err
}
// Destructive reset — safe now that drift has been ruled out.
for _, t := range summary.Targets {
if err := r.Store.ResetChunkStages(r.Book.BookID, t.Chapter, t.ChunkIdx, t.Stages); err != nil {

View file

@ -461,6 +461,9 @@ func TestRedriveResnapshotAcceptsDrift(t *testing.T) {
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true // <-- what `redrive --resnapshot` sets
// …and what `redrive --accept-rebill` sets: the re-pin is permitted by the first flag, the AMOUNT it
// re-pays is consented to by the second (D20.2-Q2, rebill.go).
r2.AcceptRebill = RebillConsent{Given: true}
sum, res, err := r2.Redrive(ctx, RedriveSelector{Chapter: -1, ChunkIdx: -1})
if err != nil {
t.Fatalf("redrive --resnapshot must NOT abort on drift, got: %v", err)

View file

@ -21,7 +21,7 @@ chunk ch2/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫ
stage=edit role=editor model=fake-model resume=false disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД f393617694a8. Судзуки шёл по коридорам Академии магии." recovered=""
chunk ch3/0 disposition=flagged flag="hard_refusal" final_text="" cost=0.00728
stage=draft role=translator model=fake-model resume=false disp=flagged flag="hard_refusal" attempts=1 escalated=true esc_model="fake-fallback" finish="refusal" cum_usd=0.00728 detail="provider finish_reason=refusal"
stage=draft role=translator model=fake-model resume=false disp=flagged flag="hard_refusal" attempts=1 escalated=true esc_model="" finish="refusal" cum_usd=0.00728 detail="provider finish_reason=refusal"
stage_text="" recovered=""
stage=edit role=editor model=fake-model resume=false disp=skipped flag="hard_refusal" attempts=0 escalated=false esc_model="" finish="" cum_usd=0 detail="skipped: a member draft chunk of this edit unit was flagged (hard_refusal)"
stage_text="" recovered=""
@ -56,7 +56,7 @@ ch1/0 edit snap_match=true content_hash=5e79dd5cbd310ac01d71ca258002beca18db715d
ch1/1 draft snap_match=true content_hash=82fb7f985de6c07182e8c09a3ceaad94f2e63745b87024087e437fba762b8227 disp=ok flag="" attempts=1 final_hash=970bd70afdd5d956114017f90ba600e63da0da7b2a1fb61908062966f8332503 cost=0.00182 escalated=false esc_model="" detail=""
ch2/0 draft snap_match=true content_hash=cfab1e09264a3a30aba95e7da1d6a269468c6aa0c1b334d9241e78bc266a3571 disp=ok flag="" attempts=1 final_hash=0c5929335c49b0f302948b0efe77ad078ec7538f0020b76c020e36272afe652e cost=0.00728 escalated=true esc_model="fake-fallback" detail=""
ch2/0 edit snap_match=true content_hash=55ffd839a4a26c03cf57e08000ab2c84b267e1429ab4a8e212d3a01fda757632 disp=ok flag="" attempts=1 final_hash=e76420deca3abe19ba49ea6de7af5938e49ef579950bf929896bf8dc6c418a28 cost=0.00182 escalated=false esc_model="" detail=""
ch3/0 draft snap_match=true content_hash=21df44eb104900664bdaaeb2f716f51dcddbc81a71d845f6ba0f91c5bc4b2cfb disp=flagged flag="hard_refusal" attempts=1 final_hash= cost=0.00728 escalated=true esc_model="fake-fallback" detail="provider finish_reason=refusal"
ch3/0 draft snap_match=true content_hash=21df44eb104900664bdaaeb2f716f51dcddbc81a71d845f6ba0f91c5bc4b2cfb disp=flagged flag="hard_refusal" attempts=1 final_hash= cost=0.00728 escalated=true esc_model="" detail="provider finish_reason=refusal"
ch3/0 edit snap_match=true content_hash= disp=skipped flag="hard_refusal" attempts=0 final_hash= cost=0 escalated=false esc_model="" detail="skipped: a member draft chunk of this edit unit was flagged (hard_refusal)"
ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=2574511b38f07a6eef3dd781907eee77cbdc73978ef28d4c9785e32d28489b58 cost=0.00182 escalated=false esc_model="" detail=""
ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=0f8084a8bcf771d895963869671f83e4bf8f80c03a491ea999278d45878fc10c cost=0.00182 escalated=false esc_model="" detail=""
@ -148,7 +148,7 @@ chunk ch2/0 disposition=ok flag="" final_text="ОТРЕДАКТИРОВАННЫ
stage=edit role=editor model=fake-model resume=true disp=ok flag="" attempts=1 escalated=false esc_model="" finish="stop" cum_usd=0.00182 detail=""
stage_text="ОТРЕДАКТИРОВАННЫЙ ПЕРЕВОД f393617694a8. Судзуки шёл по коридорам Академии магии." recovered=""
chunk ch3/0 disposition=flagged flag="hard_refusal" final_text="" cost=0
stage=draft role=translator model=fake-model resume=true disp=flagged flag="hard_refusal" attempts=1 escalated=true esc_model="fake-fallback" finish="" cum_usd=0.00728 detail="provider finish_reason=refusal"
stage=draft role=translator model=fake-model resume=true disp=flagged flag="hard_refusal" attempts=1 escalated=true esc_model="" finish="" cum_usd=0.00728 detail="provider finish_reason=refusal"
stage_text="" recovered=""
stage=edit role=editor model=fake-model resume=false disp=skipped flag="hard_refusal" attempts=0 escalated=false esc_model="" finish="" cum_usd=0 detail="skipped: a member draft chunk of this edit unit was flagged (hard_refusal)"
stage_text="" recovered=""
@ -183,7 +183,7 @@ ch1/0 edit snap_match=true content_hash=5e79dd5cbd310ac01d71ca258002beca18db715d
ch1/1 draft snap_match=true content_hash=82fb7f985de6c07182e8c09a3ceaad94f2e63745b87024087e437fba762b8227 disp=ok flag="" attempts=1 final_hash=970bd70afdd5d956114017f90ba600e63da0da7b2a1fb61908062966f8332503 cost=0.00182 escalated=false esc_model="" detail=""
ch2/0 draft snap_match=true content_hash=cfab1e09264a3a30aba95e7da1d6a269468c6aa0c1b334d9241e78bc266a3571 disp=ok flag="" attempts=1 final_hash=0c5929335c49b0f302948b0efe77ad078ec7538f0020b76c020e36272afe652e cost=0.00728 escalated=true esc_model="fake-fallback" detail=""
ch2/0 edit snap_match=true content_hash=55ffd839a4a26c03cf57e08000ab2c84b267e1429ab4a8e212d3a01fda757632 disp=ok flag="" attempts=1 final_hash=e76420deca3abe19ba49ea6de7af5938e49ef579950bf929896bf8dc6c418a28 cost=0.00182 escalated=false esc_model="" detail=""
ch3/0 draft snap_match=true content_hash=21df44eb104900664bdaaeb2f716f51dcddbc81a71d845f6ba0f91c5bc4b2cfb disp=flagged flag="hard_refusal" attempts=1 final_hash= cost=0.00728 escalated=true esc_model="fake-fallback" detail="provider finish_reason=refusal"
ch3/0 draft snap_match=true content_hash=21df44eb104900664bdaaeb2f716f51dcddbc81a71d845f6ba0f91c5bc4b2cfb disp=flagged flag="hard_refusal" attempts=1 final_hash= cost=0.00728 escalated=true esc_model="" detail="provider finish_reason=refusal"
ch3/0 edit snap_match=true content_hash= disp=skipped flag="hard_refusal" attempts=0 final_hash= cost=0 escalated=false esc_model="" detail="skipped: a member draft chunk of this edit unit was flagged (hard_refusal)"
ch4/0 draft snap_match=true content_hash=7aabdcb89dea365c95363b2662144ca1a10b307d377f47a8e7cc19b5b872d287 disp=ok flag="" attempts=1 final_hash=2574511b38f07a6eef3dd781907eee77cbdc73978ef28d4c9785e32d28489b58 cost=0.00182 escalated=false esc_model="" detail=""
ch4/0 edit snap_match=true content_hash=2854dfe1648b0cf0cbb683cb7ce94c5eedc9223b2ce20c99dc008c7add6317ef disp=ok flag="" attempts=1 final_hash=0f8084a8bcf771d895963869671f83e4bf8f80c03a491ea999278d45878fc10c cost=0.00182 escalated=false esc_model="" detail=""

View file

@ -416,6 +416,9 @@ func TestWaveMinedSignDoesNotRebillDraft(t *testing.T) {
r2 := newRunner(t, bookPath)
defer r2.Close()
r2.Resnapshot = true
// The mined sign IS the ratified single overpay, so it now also needs the Р6 consent to that amount
// (D20.2-Q2, rebill.go): --resnapshot grants the re-pin, --accept-rebill consents to the spend.
r2.AcceptRebill = RebillConsent{Given: true}
if _, err := r2.TranslateBook(ctx); err != nil {
t.Fatal(err)
}

View file

@ -19,20 +19,28 @@ import (
// ChunkStatus is one (book, chapter, chunk, stage) disposition row.
type ChunkStatus struct {
BookID string
Chapter int
ChunkIdx int
Stage string
SnapshotID string
ContentHash string // signature of the rendered msgs (the source is NOT in the snapshot); guards the resume fast-path against serving a stale translation after a source edit
Disposition string // ok | flagged | skipped
FlagReason string // "" when ok
Attempts int // number of attempts made for this chunk×stage
FinalHash string // request_hash of the authoritative checkpoint (ok path)
CostUSD float64 // sum across all attempts (F3-honest)
Detail string
Escalated bool // a single-hop fallback was used for this chunk×stage (D12/D15.3 telemetry)
EscalationModel string // the fallback model that answered ("" when not escalated)
BookID string
Chapter int
ChunkIdx int
Stage string
SnapshotID string
ContentHash string // signature of the rendered msgs (the source is NOT in the snapshot); guards the resume fast-path against serving a stale translation after a source edit
Disposition string // ok | flagged | skipped
FlagReason string // "" when ok
Attempts int // number of attempts made for this chunk×stage
FinalHash string // request_hash of the authoritative checkpoint (ok path)
CostUSD float64 // sum across all attempts (F3-honest)
Detail string
Escalated bool // a single-hop fallback was ATTEMPTED for this chunk×stage (D12/D15.3 telemetry)
// EscalationModel is the fallback model whose output became AUTHORITATIVE for this row — the model
// whose bytes shipped. It is EMPTY when no hop ran AND when a hop ran but also failed (the primary's
// flag stands, so no fallback output was used); `Escalated` alone records that an attempt was made
// and billed, and the hop's identity survives in its own checkpoint / request_log row.
//
// HISTORICAL MIXTURE (pack-18, deliberate — no migration): rows written before that pack carry the
// ATTEMPTED model instead, so a flagged row from an older run may name a fallback that in fact
// refused. Old rows are not rewritten; a book re-run under the current code re-resolves them.
EscalationModel string
}
// UpsertChunkStatus writes (or overwrites) the disposition row. Overwrite is the

File diff suppressed because one or more lines are too long

View file

@ -11,7 +11,7 @@
## Структура
- `architecture/` — синтез. **Источник истины по решениям — [`05-decisions-log.md`](architecture/05-decisions-log.md) (D1D39.30); при конфликте с любым доком он выше.**
- `architecture/` — синтез. **Источник истины по решениям — [`05-decisions-log.md`](architecture/05-decisions-log.md) (D1D39.31); при конфликте с любым доком он выше.**
- `01-decisions.md` — принципы Р1Р10; `02-mvp-plan.md` — фазы и приёмка (v3, 09.07); `03-implementation-notes.md` — контракты Фазы 0; `04-unhappy-paths.md` — ~70 режимов отказа → механизм; `06-memory-risk-registry.md` — реестр рисков банка памяти; **[`09-target-architecture.md`](architecture/09-target-architecture.md) — целевая 7-слойная архитектура (D39; статус стройки — шапка-таблица; инвариант общности §0.1)** · [`10-prompt-architecture.md`](architecture/10-prompt-architecture.md) — консолидированная промпт-заметка (концерн 4) · [`12-go-style-notes.md`](architecture/12-go-style-notes.md) — норматив общности §0 + Go-ответы. **Исполненные (архив 25.07, санкция владельца, → `archive/architecture/`):** [`07-strategic-review.md`](archive/architecture/07-strategic-review.md) (стратаудит 09.07 — курс исполнен) · [`08-sync-audit-ledger.md`](archive/architecture/08-sync-audit-ledger.md) (ледджер синк-аудита — отработан) · [`11-implementation-plan.md`](archive/architecture/11-implementation-plan.md) (план пака-11 — исполнен целиком); `components.puml`/`pipeline.puml` — диаграммы (перерисованы 25.07 под пост-пак-16 реальность: волновой исполнитель, пакетный сплит пака-15, repair-петля за `enabled:false`, generic content-labels D39.25; владелец смотрит PlantUML-расширением VS Code; вручную НЕ рендерить).
- `experiments/` — эмпирика «Полигона»: `00-provider-quirks` (читать перед любым вызовом провайдера), `01-token-calibration`, `02-refusal-benchmark`, `03-local-stand`, `04-editor-quality`, `06-local-extraction`, `07-coverage-precision`, `08-cost-model-v2` (актуальная денежная модель), `09-pilot-protocol` (пилот Ф2.5 + поправки D13), `10-explicit-benchmark` (18+ violence-рука канала B), `11-erotica-benchmark` (erotica по трём парам/регистрам — закрытие D14.4, D22), `12-quality-diagnosis`/`13-translator-bakeoff`/`14-quality-empirics`/`14b-meaning-battery` (дуга качества «мерить→строить», закрыты → D30/D32/D37/D38), `15-segmentation-empirics` (exp15: когезия под floor-шумом, фертильность — закрыт → D39.7/D39.8), `16-bank-mining` (exp16: WHICH/WHAT банк-майнинга — закрыт → D39.10).
- `research/` — фактура исследований 0405.07: `0110` базовые, `11-gap-*` добор критиком, `12-*` режимы отказа/отзывы/таксономии (+ два внешних материала с провенанс-шапками), `13` валидация памяти, `14` адаптивная память, `15` голос и состояние (принят, D21), **`16` ридер-IDE (принят с ревью-шапкой, D29)**, **`17` внешняя критика GPT-5.6 (принят с ревью-шапкой, D25)** — у 16/17 читать шапку прежде тела. **`18` рычаги качества (два отчёта, D36)** · **`19` нарезка+когезия+контракт t/e (D39.1)** · **`20` банк-майнинг W1.5 (D39.6)** · **`21` обзор LLM-транспорта чужих харнессов (23.07: наш транспорт опережает/вровень со всеми 11)** · **`22` доменные харнессы перевода (24.07: калибрующий — ядро подтверждено, впереди COGS/18+/общность/измеренность, позади Q4-выпуск и gate+repair-петля; сиквел `02`/`05`)** — у всех ревью-шапки. ⚠ Часть под superseded-баннерами (01/02/03/04/05/09 и gap-1/2/5) — **читай баннер прежде содержимого**.

View file

@ -915,3 +915,24 @@ API-529-долг закрыт: 8-осевой refute-by-default воркфлоу
3. **Две строки `accepts_labels` ПОДПИСАНЫ и ПРИМЕНЕНЫ мной в `backend/configs/models.yaml`:** `xai: accepts_labels: [sexually-explicit]` (xAI AUP Effective 2026-06-26 + Enterprise ToS Last Updated 2026-05-12) и `mistral: accepts_labels: [sexually-explicit]` (Mistral Usage Policy Effective 2026-06-11, хост `legal.mistral.ai`). Обоснование — в комментариях у строк, цитаты — в `experiments/00-provider-quirks.md`. **Ни одна книга лейблов не несёт, поэтому применение строк НИЧЕГО не запускает и ничего не двигает** (проверено: `go test ./internal/config/` зелёный; строки — данные провайдера, вне хешей и вне снапшотов). Механизм перестал быть пустым и готов к первой explicit-книге; для неё по-прежнему обязательны предусловие D22.7 (L3-скрин) и Q2 (выбор редактора под лейблом).
**Остались открытыми (не блокируют, у владельца):** чтение Gemini-оговорки «for example … pornography or sexual gratification» (сужение vs иллюстрация) · связывает ли нас OpenAI Sharing & publication policy 2022 (единственное, что держит обе клетки OpenAI в UNCLEAR) · применимость Ollama ToS §4 к локальному инференсу · различать ли в конфиге причину пустоты (`FORBIDDEN` vs `UNCLEAR`) · дата заведения аккаунта OpenAI (§17 привязывает редакцию политик к дате договора) · ре-чек политик по хешу перед платными прогонами (триггеры: головной Google ToS 30.07.2026, квартал 25.10.2026). **Продуктовое (Ф3, вне движка):** Gemini API Additional ToS запрещает клиентов, «likely to be accessed by individuals under 18» — обязательство на конечный продукт (ридер/публикация).
## D39.31 — Пак-18 «долги контракта» ПРИНЯТ и залендён: `--accept-rebill` построен, семантика `escalation_model` исправлена под санкционированный пере-капчер (новый baseline `211c9013…089045d6`), два нита общности отложены ПО ЗАМЕРУ (25.07, оркестратор №8). ✅
**Отчёт:** `docs/archive/reports/PACK18_DEBTS_REPORT_2026-07-25.md` (с ревью-шапкой). Сессия закрыла два долга из трёх и отложила третий с измеренной причиной; 19 собственных мутаций, из них две сначала ВЫЖИЛИ и вскрыли дыры в её же тестах (включая регресс-класс D20.4 «распарсено, но не проведено в раннер») — обе закрыты новыми тестами, а не списаны.
**Моя приёмка — execute-first, всё пере-ранено:** `go build`/`go vet` чисты · `gofmt` — только `internal/llm/llm.go`, **воспроизводится на HEAD** (проверил: файл не трогали) · **`-race` 12 пакетов ok, 0 FAIL** · `-shuffle=on` по новым тестам зелёный · **парити EXACT** `n=13618 catastrophe{方源:0 蛊:1 蛊师:2 古月:22} recall 0.9655 (56/58)` · **ценз диффом ИМЁН: 455→475, удалено 0**, добавлены ровно 20 названных.
**Санкционированный пере-капчер golden — проверен МАШИННО, ровно в границах санкции D39.26/D39.30-дисциплины:** сдвинулись **4 строки**, все на `ch3/0 draft` (кейс «хоп тоже отказал»), значение `esc_model="fake-fallback"``""`. Мой контроль: замаскировал `esc_model="…"` плейсхолдером в HEAD-версии и в рабочей — **файлы стали БАЙТ-ИДЕНТИЧНЫ**; `brief_hash`, `snapshot_draft_payload`, `snapshot_edit_payload`, `memory_version` совпадают построчно ⇒ **вердикт-изменение не утащило с собой ни одной другой оси, пере-оплаты нет**. **Новый baseline golden — `211c9013f5cfa31562857cc9518488393a647cce489d37113402b159089045d6`** (прежний `f7641993…4f0020f8` закрыт этим блоком).
**Мои независимые мутации (не из списка сессии) — обе пойманы именованными тестами:** (D1) ратифицированную формулу порога `min($0.50, 5%×Projected)` заменил на `max` → красные `TestRebillConsentThresholdFormula` + пять смежных, включая оба CLI-провода; (D2) в проекцию пустил строки, которые резюмятся за $0 (завышение суммы, под которой просят подпись) → красные `TestRebillProjectionExcludesSkippedAndUnchanged`, `…IsPerWave`, `…IgnoresRetiredStages`.
**РАТИФИЦИРОВАНО:**
1. **Развилка сессии «`--resnapshot` НЕ является согласием» — принята, и это даже не девиация:** ратифицированная спека прямо говорит «флаг `--accept-rebill[=usd]` (опциональный потолок суммы, **отдельный от `--resnapshot`-алиаса**)» (`backend/docs/D15.2-content-addressed-resume-spec.md:590`). Аргумент сессии сверх того верен: при алиас-прочтении порог D20.2-Q2 становится мёртвым кодом, потому что на пути `translate` `--resnapshot` требуется в любом случае и сам же был бы согласием — то есть гейт, который не может выстрелить, класс, который загрузчик уже отвергает. Цена (4 существующих теста получили строку согласия) принята.
2. **Гранулярность подтверждена по коду сессией, развилки не возникло:** каждая оплаченная строка под чужим снапшотом действительно пере-оплачивается; каскад §7.1-бис зафиксирован ВЫРОЖДЕННЫМ (пере-оплачивается всё, дополнять нечего), `guard_hash` не строился — ровно граница D39.31-предшественника (промт пака-18).
3. **Дефект честности, найденный сессией у себя, — правильного класса:** проекция считала строки-сироты (позиции, исчезнувшие из манифеста; снятые стадии) и **завышала сумму, под которой просят подпись**. Исправлено, запинено, отмутировано. Фиксирую как норму: сумма в запросе согласия — это утверждение о деньгах, и завышение здесь так же недопустимо, как занижение.
4. **`escalation_model` = авторитетная модель.** Идентичность отказавшего хопа не теряется (живёт в его чекпойнте и строке `request_log`) и это запинено тестом; миграции нет, исторические строки не переписываются — смешанная семантика в старых данных принята осознанно.
5. **Два нита общности — «отложено-с-записью», отсрочка ИЗМЕРЕНА, а не заявлена.** Одна новая строка в `configs/langpacks/zh-ru/dc-checkers.txt` двигает `pack.Version()` `a7d4be2c0465``133d71e8d132` и оба волновых снапшота, а стендовые книги объявляют `langpack_root` ровно на этот каталог (проверил: `/home/ubuntu/books/gu-zhenren/rerun2/book.yaml`) ⇒ `--resnapshot` и потеря $0-резюма драфта под замер остатка пака-16. Отдельно принимаю ОПРЕДЕЛИТЕЛЬНЫЙ аргумент по Detail-строкам: дефолт шаблона обязан рендерить сегодняшнюю строку байт-в-байт, значит 时辰-литерал остаётся в Go при любой форме данных — байт-нейтральной формы у этого нита не существует в принципе. **Оба нита едут на ближайший ПОЛНЫЙ `--resnapshot`** (план — в отчёте); до тех пор ограничение зафиксировано, а не забыто.
**Координационная заметка (моя ошибка, фиксирую по норме «признавай явно»):** запись бэкенд-сессии в `docs/PROGRESS.md` уехала в мой предыдущий коммит `3bc9eb4` — я собрал общий файл целиком (`git add docs/PROGRESS.md`), пока её текст лежал в рабочем дереве. Вреда нет (текст легитимный и остаётся), но правило уточняю для себя и преемников: **для ОБЩИХ файлов (`PROGRESS.md`, D-лог) при живой параллельной сессии смотреть `git diff --cached` ПО СОДЕРЖИМОМУ, а не по списку имён** — списка имён недостаточно, он не показывает чужой абзац внутри моего файла.
**Очередь бэкенда после пака-18:** долгов контракта не осталось; открыты — ru-target (слой 7, ждёт своего дизайн-пака) · два нита общности (на ближайший полный resnapshot) · масштаб целой книги · Ф2-механизмы (голос/состояние D21, native-Gemini судья) · извлечение дискурс-норм из тел промтов (слой 2).

View file

@ -0,0 +1,348 @@
# Отчёт бэкенд-сессии: ПАК-18 «долги контракта» (25.07.2026)
> **Ревью-шапка оркестратора №8 (25.07, приёмка execute-first, инлайн; ПРИНЯТ — `D39.31`). 0 блокеров.** Ре-ранил сам: build/vet чисты · `gofmt` — только `internal/llm/llm.go`, воспроизводится на HEAD (файл не трогали) · **`-race` 12/12** · `-shuffle=on` зелёный · **парити EXACT `n=13618 … 0.9655 (56/58)`** · **ценз диффом ИМЁН 455→475, удалено 0**.
> **Санкционированный пере-капчер проверен МАШИННО:** замаскировал `esc_model="…"` в HEAD-версии и в рабочей — **файлы байт-идентичны**; `brief_hash`, оба `snapshot_*_payload`, `memory_version` совпадают построчно ⇒ вердикт-изменение не утащило ни одной другой оси, пере-оплаты нет. Сдвинулись ровно 4 строки, все `ch3/0 draft`. **Новый baseline golden — `211c9013…089045d6`.**
> **Мои независимые мутации (не из списка сессии), обе пойманы:** формула порога `min``max` → красный `TestRebillConsentThresholdFormula` + пять смежных, включая оба CLI-провода; в проекцию пущены строки, резюмящиеся за $0 (завышение суммы под подпись) → красные три `TestRebillProjection*`.
> **Развилка «`--resnapshot` ≠ согласие» — принята и даже не является девиацией:** спека прямо говорит «отдельный от `--resnapshot`-алиаса» (`backend/docs/D15.2-content-addressed-resume-spec.md:590`); аргумент сессии сверх того верен — при алиас-прочтении ратифицированный порог становится гейтом, который не может выстрелить.
> **Отсрочка п.3 принята как ИЗМЕРЕННАЯ:** `pack.Version()` `a7d4be2c0465``133d71e8d132`, стендовая книга объявляет `langpack_root` ровно на репозиторный каталог (проверил `/home/ubuntu/books/gu-zhenren/rerun2/book.yaml`); определительный аргумент по Detail-строкам (дефолт шаблона обязан рендерить текущую строку ⇒ литерал остаётся в Go) принят — байт-нейтральной формы у этого нита не существует. Оба нита едут на ближайший полный `--resnapshot`.
> **Дефект честности, найденный сессией у себя** (проекция считала строки-сироты и завышала сумму под подпись), — правильного класса; зафиксирован нормой в D39.31 п.3.
**Промт:** `docs/BACKEND_PACK18_DEBTS_SESSION_PROMPT.md` (оркестратор №8, выдан 25.07).
**Итог:** пункты 1 и 2 ПОСТРОЕНЫ и проверены исполнением; пункт 3 **ОТЛОЖЕН** с доказательством
исполнением, что байт-нейтральной формы не существует (принятый промтом исход).
**Сессия не коммитила.** `git mv` не выполнялось. Платных вызовов — ноль.
**Аддендумы:** за время сессии релеем не приходило ни одного (строка-подтверждение по требованию промта).
---
## Эхо-блок (сверка со скоупом, выдан ДО работы)
Скоуп: `--accept-rebill[=usd]` на снапшотной гранулярности · `escalation_model` = авторитетная модель ·
два нита общности УСЛОВНО. Инварианты: согласие ДО денег · golden байт-идентичен кроме санкционированного
пере-капчера п.2 (только колонки `esc_model`) · ни одна ось `RequestHash`/снапшота не двигается ·
`CheapGateVersion`/`pack.Version()` не двигаются · общность §0 · $0-контракт read-only путей.
Не делать: `guard_hash`/пер-чанковый dry-run · ru-target долг · `accepts_labels` · L3 · live 18+ ·
миграции durable-строк · расширение скоупа без пинга.
---
## ПУНКТ 1 — `--accept-rebill[=usd]`: согласие на пере-оплату. ПОСТРОЕН
### Что построено
Новый файл `backend/internal/pipeline/rebill.go` (единственное место логики) + проводка:
| Файл | Что |
|---|---|
| `internal/pipeline/rebill.go` | `RebillConsent`, `RebillProjection`, `projectRebill`, `projectBookUSD`, `rebillConsentThreshold`, `checkRebillConsent` |
| `internal/pipeline/runner.go:52` | поле `AcceptRebill RebillConsent` (ортогонально `Resnapshot`) |
| `internal/pipeline/bookrun.go:137` | гейт в `TranslateBook` — после `seedGlossary`+`SplitChunks`, ДО волн (значит до первой `Reserve`) |
| `internal/pipeline/status.go:682` | гейт в `Redrive` — ДО деструктивного `ResetChunkStages` |
| `internal/pipeline/status.go:485` | `ProjectedBookUSD` вынесен в общий `projectBookUSD` (одно определение на порог и на `status`) |
| `internal/config/book.go:100` | `rebill_consent_usd` — переопределение порога книгой (0 = ратифицированный дефолт) |
| `cmd/tmctl/invocation.go` | `--accept-rebill[=usd]` через `flag.Value`+`IsBoolFlag` |
| `cmd/tmctl/main.go` | проводка в `translate` и `redrive` |
**Формула (ратифицированная, D20.2-Q2):** порог = `min($0.50, 5%×ProjectedBookUSD)`, при
`ProjectedBookUSD = 0` — абсолютный флор `$0.50`. Книга может переопределить (`rebill_consent_usd`).
**Проекция (снапшотная гранулярность):** Σ `chunk_status.cost_usd` по строкам, которые
(а) несут снапшот, (б) были ОПЛАЧЕНЫ (`ok`/`flagged`; `skipped` не доезжал до провайдера), (в) чей
снапшот ≠ текущему снапшоту ЕГО ВОЛНЫ, (г) чьи стадия и позиция ещё существуют.
### «РЕШИ САМ И АРГУМЕНТИРУЙ» — мои решения
**1. Где живёт вычисление.** Прайор промта принят: рядом со `status`-проекцией, но с уточнением —
`ProjectedBookUSD` **вынесен из `Status` в общую функцию** `projectBookUSD` (`rebill.go`), которую
теперь зовут ОБЕ стороны. Причина: порог = 5% от этого числа; порог, посчитанный от второй,
дрейфующей копии той же величины — ровно тот класс, ради которого в паке-16 выносили `memberDrops`.
Вынос доказан байт-нейтральным исполнением (golden и `status`-тесты зелёные без правок).
**2. Форма сообщения об отказе.** Несёт: сумму (6 знаков), число единиц (chunk×stage), порог И его
происхождение (`5% от проекции $X` / `флор $0.50` / `book.rebill_consent_usd`), строку
**«NOTHING was reserved and no row was touched»**, и точную команду-лекарство. Отдельная форма для
превышенного потолка (`--accept-rebill=1.50` при проекции $2.41). Когда `--resnapshot` не передан,
добавляется хвост «run also needs --resnapshot» — оператор получает ОБА флага сразу.
**3. Как флаг ложится на `translate` и `redrive` — ЭТО РАЗВИЛКА, РЕШЁННАЯ МНОЮ, ОТМЕТЬТЕ ПРИ ЛЕНДИНГЕ.**
Спека §7.3 говорит: «`--resnapshot` остаётся алиасом-синонимом (семантика — прими проецируемый
re-bill)». **Я это НЕ перенёс.** Флаги сделаны ортогональными: `--resnapshot` = разрешение
пере-пиннить, `--accept-rebill` = согласие на СУММУ; сверх порога нужны оба. Аргументы:
- оговорка спеки живёт в §7 «**Что демонтируется**» — в мире, где fail-loud снапшот-гейт УДАЛЁН и
`--resnapshot` иначе теряет смысл. Гейт не демонтирован (`guard_hash` не строим), поэтому у
`--resnapshot` осталось своё живое значение;
- при алиас-прочтении **ратифицированный порог становится мёртвым кодом**: на пути `translate`
`--resnapshot` требуется в любом случае и сам же был бы согласием — порогу нечего гейтить.
Ортогональность — единственное прочтение, при котором `min($0.50, 5%×…)` вообще что-то делает;
- Р6 = согласие на КОНКРЕТНУЮ трату. Флаг, чьё имя и help-строка не упоминают денег, её не несёт;
а долг сформулирован в трекере ровно так: «`--resnapshot` пере-пиннит **без суммы**»;
- направление строго безопасное: ни один прогон, который раньше отказывал, теперь не проходит;
часть прогонов, которые раньше проходили, теперь останавливаются с напечатанной суммой.
**Цена решения (честно):** 4 существующих теста, гонявших `--resnapshot` в одиночку, получили строку
`AcceptRebill = RebillConsent{Given: true}` (перечислены ниже). Это и есть смена контракта.
**4. Проекция ниже порога.** Прайор промта принят — продолжаем. Но не молча-невидимо: сумма и число
единиц уходят в лог (`Info`, «continuing without asking»). Деньги обязаны быть видны, даже когда не
спрашивают.
**5. Потолок ниже порога.** Именованный потолок honoured **всегда**, даже когда проекция ниже порога:
`--accept-rebill=0.001` — это инструкция, а не форма согласия. Оператор, написавший «не больше $X»,
не должен быть оплачен на $X+ε на том основании, что сумма была мелкой.
**6. Ловушка stdlib (найдена исполнением, а не чтением).** `IsBoolFlag` — единственный способ дать
флагу опциональное значение, но тогда `--accept-rebill 1.50` (через пробел) **не связывает сумму**:
флаг читается голым, `1.50` становится бесхозным аргументом, и потолок молча превращается в
безлимитное согласие. Это отказ с показом правильного написания. Отказ **прицельный** (только при
голом флаге + бесхозный аргумент) — исторически терпимый бесхозный аргумент везде ещё терпим
(запинено тестом).
### Граница скоупа — фиксирую строками, как требует промт
- **Каскад §7.1-бис на снапшотной гранулярности ВЫРОЖДЕН.** Пере-оплачивается вся волна целиком,
поэтому правило «стадия строго ниже re-bill-единицы сама считается re-bill» не добавляет ни одной
единицы. Ратифицированный порядок «каскад ложится ПЕРЕД семантикой флага» не нарушен — он
неприменим.
- **Ветка `EstimateUSD` из §7.2 не построена, и у неё здесь нет предмета.** Она существует для
каскадных downstream-единиц без прошлой цены; на снапшотной гранулярности каждая учтённая единица
ИМЕЕТ хранимую цену — именно за то, что была оплачена. Оплаченная строка с `cost_usd = 0` — это
честно $0-модель (local/нулевой прайс), и её вклад $0 верен, а не пробел. Строить мёртвую
машинерию (least mechanism) не стал.
- **Развилки «снапшотной гранулярности недостаточно» не возникло** — пинг не потребовался. Проверено
по коду: под `--resnapshot` fast-path `chunk_status` требует `cs.SnapshotID == snapID`
(`stagerun.go:86`), промах ⇒ attempt-цикл ⇒ `RequestHash` несёт `SnapshotID` ⇒ промах чекпоинта ⇒
свежий платный вызов. То есть **каждая** оплаченная строка под чужим снапшотом действительно
пере-оплачивается — сумма честна, а не консервативна.
- **НЕ покрыто (документировано, не умолчано): КОНТЕНТНАЯ ось.** Правка исходника, не сдвинувшая
манифест, меняет `content_hash` чанка, но не снапшот — она пере-оплатит ровно тронутые чанки и в
проекции невидима. Это та же оговорка, что несёт `status.bookChunks` (`status.go:169-175`), и
половина content-addressed resume v3, а не этого гейта.
### Дефект, найденный МОИМ ревью собственного кода (и починенный)
Первая редакция считала строки-СИРОТЫ: позиции, исчезнувшие из манифеста (исходник укоротили) и
стадии, которых в пайплайне больше нет. Их никто не пере-запустит ⇒ проекция завышала сумму, под
которой просят подпись. Завышение — безопасное направление, но число, которое просят одобрить и
потом не тратят, — это ровно то, отчего такие числа перестают читать. Обе сироты исключены,
обе запинены тестами (`TestRebillProjectionIgnoresVanishedChunks`, `…IgnoresRetiredStages`) и
мутациями M16/M17.
---
## ПУНКТ 2 — `escalation_model` = модель, чей выход АВТОРИТЕТЕН. ПОСТРОЕН
**Прайор промта принят целиком, альтернативы не приношу** — аргумент «терять идентичность отказавшего
хопа нельзя» проверен и не подтвердился: идентичность НЕ теряется. Отказавший хоп сохраняет
(а) собственный durable-чекпоинт под своим `request_hash` на модели хопа, (б) собственную строку
`request_log` с `model_requested=fake-fallback` и `degraded=hard_refusal`. Проверено исполнением:
в golden секция `request_log` при пере-капчере **не изменилась ни одной строкой**, а тест
`TestRunnerEscalationFallbackAlsoFailsFlags` теперь явно считает эту строку.
**Правка:** `stagerun.go:158-193``escalated = true` выставляется при любом состоявшемся хопе
(деньги/телеметрия), `escModel` — ТОЛЬКО когда выход хопа стал авторитетным (`last = esc.fb`, т.е.
хоп прошёл ре-гейт или дал косметический `sanitizer_stripped`, который отгружается).
**Миграции нет, старые строки не переписываются.** Смешанная семантика в исторических данных
допустима и **записана в durable-месте**, где её увидит следующий читатель: доккоммент поля
`store.ChunkStatus.EscalationModel` (`internal/store/chunkstatus.go:35-43`) несёт и новую семантику,
и абзац «HISTORICAL MIXTURE (pack-18, deliberate — no migration)».
**В `tmctl report` баннер НЕ добавлял — аргумент.** `escalation_model` не печатается ни одной
поверхностью CLI (проверено грепом: потребители — только `stagerun`/`resume`/`store`/golden;
`render.go` печатает `Escalations: N`, а не модель). Отличить старую строку от новой в данных
нечем — маркера нет. Значит баннер был бы безусловным и неактивируемым текстом, который ничего не
говорит оператору о конкретной строке. Место факта — схема (сделано) и D-лог.
### Пере-капчер golden — дисциплина приёмки выполнена
`TM_UPDATE_GOLDEN=1 go test ./internal/pipeline/ -run TestGolden`
**Маскированный структурный дифф (Ш-1), напечатанный ДО записи:**
```
golden masked-diff (Ш-1): 8 verdict/wire change line(s), 0 version-only line(s)
~ stage=draft … disp=flagged flag="hard_refusal" … escalated=true esc_model="" finish="refusal" …
~ ch3/0 draft … disp=flagged flag="hard_refusal" … escalated=true esc_model="" detail="provider finish_reason=refusal"
~ stage=draft … resume=true … escalated=true esc_model="" finish="" …
~ ch3/0 draft … escalated=true esc_model="" detail="provider finish_reason=refusal"
```
**Сырой дифф файла: 4 удалённых / 4 добавленных строки, все на `ch3/0 draft`** — единственном хопе
фикстуры, который ОТКАЗАЛ. Проверено машинно (не глазами): скрипт заменил `esc_model="…"` на
плейсхолдер в обеих сторонах каждой пары и сравнил — **`only esc_model columns moved: True`**.
Ни одной другой сдвинувшейся строки ⇒ стоп-условие промта не сработало.
**Ни одна ось `RequestHash`/снапшота не двинулась — доказано исполнением:**
```
snapshot_draft: HEAD vs worktree — IDENTICAL
snapshot_edit: HEAD vs worktree — IDENTICAL
brief_hash: IDENTICAL
memory_version: IDENTICAL
base_memory_version: IDENTICAL
```
плюс в самих изменённых строках `content_hash`/`final_hash`/`cost` побайтно те же; секции
`request_log`, `retrieval_state` и `wire bodies` в диффе отсутствуют вовсе. Пере-оплаты нет.
Golden: `f7641993…4f0020f8`**`211c9013f5cfa31562857cc9518488393a647cce489d37113402b159089045d6`**.
`testdata/` в остальном чист (`git status` показывает только `capture.golden`).
---
## ПУНКТ 3 — два нита общности. **ОТЛОЖЕНЫ** (байт-нейтральной формы не существует)
Промт: «берутся ТОЛЬКО в байт-нейтральной форме… Если не выходит — отложи и скажи явно с причиной;
это принятый исход». Не вышло. **Причина измерена, а не предположена.**
### Замер (throwaway-проба в песочнице, репозиторий не тронут)
Собрал книгу с `langpack_root` (как боевые стендовые), снял оба волновых снапшота, дописал в копию
`configs/langpacks/zh-ru/dc-checkers.txt` **одну строку** (`pattern\tshichen_hours\t2`), пересобрал:
```
PROBE pack.Version(): langpack-v2-a7d4be2c0465 → langpack-v2-133d71e8d132 moved=true
PROBE snapshot_draft: c4e204b020bbc550437aa567 → a234e731ebabb78ec12dc6d1 moved=true
PROBE snapshot_edit: 84abf42f15c9df7f0cf22a2b → 7c712192ada86df74a0b3b7e moved=true
```
`a7d4be2c0465` — та самая версия пака, что записана в PROGRESS за пак-16. Двигаются **ОБЕ** волны
(`LangpackVersion` фолдится в общем билдере `buildSnapshotID`, `snapshot.go:428`, который зовут обе).
**Кого это убивает:** стендовые книги ссылаются `langpack_root` ровно на этот каталог репозитория —
`/home/ubuntu/books/gu-zhenren/rerun2/book.yaml:23`, `book-dspro.yaml:22`, `book-mistral.yaml:22`.
То есть любая правка данных пары стирает $0-резюм ДРАФТА, на котором должен поехать замер остатка
пака-16.
### Почему закрыты и остальные двери
| Дом данных | Итог |
|---|---|
| langpack (`dc-checkers.txt`) | версия = sha256 БАЙТОВ файла ⇒ любая новая строка двигает оба снапшота. **Замерено выше.** |
| пар-слой `configs/pairs/<пара>.yaml`, **фолдится** | новое поле с значением ⇒ payload снапшота меняется ⇒ тот же класс. |
| пар-слой, **НЕ фолдится** | тихо снимает версионирование с правила, которое `CheapGateVersion` версионирует БЕЗУСЛОВНО (`snapshot.go:432`; `checkers.go:27` прямо: «Version rides the langpack Version() (data) + CheapGateVersion (algorithm)»). Это вторая половина запрета промта: «молча НЕ бампить, поменяв версионируемое правило, — тоже [нельзя]». |
| пар-слой, фолдится через `omitempty`, zh-ru НЕ шлёт ключ | байт-нейтрально — но тогда «2» остаётся в Go как молчаливый дефолт для любой пары, что шлёт DC1-паттерны без коэффициента. Утечка не устранена, а **раздвоена на два дома** — против принципа, который этот же файл декларирует про `hourWordRE` («how this target renders the double-hour is one fact with one home», `checkers.go:53-56`). |
**Для Detail-строк ограничение ещё жёстче и является определительным:** байт-нейтральность ТРЕБУЕТ,
чтобы дефолт шаблона рендерил ровно текущую строку — то есть 时辰-литерал обязан остаться в Go
дословно. Нит про Detail-строки байт-нейтрально нереализуем в принципе, а не по обстоятельствам.
Ничего в `internal/checks/` **не тронуто**: `CheapGateVersion` и `pack.Version()` не двигались,
golden не трогался этим пунктом, парити EXACT воспроизведена.
### Готовый план на момент, когда снапшот и так поедет (одним шагом, ~час)
Пять Go-сайтов, все в двух файлах (перечислены грепом, не памятью):
- множитель: `checkers.go:174` (`expectedHours := n * 2`), `repair.go:124` (`ruNum == n*2`),
`repair.go:138` (`~%d h`, `n*2`), `repair.go:392` (`newNum == oldNum*2`);
- 时辰-текст: `checkers.go:153`, `checkers.go:176`, `repair.go:103`, `repair.go:138`.
Форма: новые ключи в `configs/langpacks/<пара>/dc-checkers.txt``unit_hours` (коэффициент) и
`dc1_detail` / `dc1_fractional_detail` (шаблоны с подстановками `{n}`/`{got}`/`{want}`/`{word}`,
без англ. диагностики в теле). **Fail-loud, а не дефолт:** пара, что шлёт `shichen_re` без
`unit_hours`, обязана падать на загрузке — иначе вторая пара молча получит «2». Версионирование
приезжает даром: байты файла уже в `pack.Version()`.
**Два кандидатных слота (решение — оркестратору/владельцу):** (а) вместе с включением платной петли
ремонта (D39.24) — она и так двигает снапшот шиппинг-волны, но НЕ драфтовой, так что правка пака
добавит пере-оплату драфта; (б) на ближайшем полном `--resnapshot` стендовых книг — тогда бесплатно
целиком. Слот (б) чище.
---
## Самопроверка ИСПОЛНЕНИЕМ (мандат владельца 12.07)
### Манифест приёмки — заявление = команда (каждая ре-ранится)
```bash
cd backend
go build ./... # ok
go vet ./... # ok
gofmt -l . # internal/llm/llm.go — ВОСПРОИЗВОДИТСЯ НА HEAD (git show HEAD:… | gofmt -l), сессия файл не трогала
go test ./... -race # 12 пакетов ok, 0 FAIL
go test ./... -shuffle=on # 12 пакетов ok, 0 FAIL
sha256sum internal/pipeline/testdata/golden/capture.golden
# 211c9013f5cfa31562857cc9518488393a647cce489d37113402b159089045d6
TM_MINER_PARITY=1 go test ./internal/miner/ -run TestMinerFullBookParity -v
# PARITY: n=13618 catastrophe{方源:0 蛊:1 蛊师:2 古月:22} recall@proposed=0.9655 (56/58 GT) — тождественно пакам 15/16/17
grep -rh "^func Test" --include=*_test.go . | sed 's/(.*//' | sort # ценз ДИФФОМ ИМЁН
```
**Ценз тестов исполнением (диффом имён, не счётчиком): 455 → 475, удалено 0.** Добавлено 20:
15 в `internal/pipeline` (`rebill_test.go` ×13, `runner_test.go` ×1 новый + 1 усиленный),
4 в `cmd/tmctl` (`invocation_test.go` ×3, `rebill_cli_test.go` ×2).
### Изменённые существующие тесты (не удалённые — по строке `AcceptRebill` каждый)
`TestRunnerSnapshotPinning` · `TestRunnerMemoryResnapshotOnApprovedChange` ·
`TestRedriveResnapshotAcceptsDrift` · `TestWaveMinedSignDoesNotRebillDraft` — все четыре гоняли
`--resnapshot` в одиночку и теперь несут явное согласие. Это прямое следствие решения №3 выше и
единственное место, где пак сменил поведение существующих сценариев.
`TestRunnerEscalationFallbackAlsoFailsFlags` дополнен тремя ассертами (не переписан).
### Сквозные сценарии через НАСТОЯЩИЙ драйвер (норма 11)
Согласие сверх порога · согласие под порогом (через `rebill_consent_usd`) · отказ без флага ·
`--accept-rebill=<сумма>` ниже проекции и выше неё · хоп ответил · хоп отказал · read-only живы ·
redrive до сброса · redrive `--dry-run` · пер-волновая проекция на mined-sign · $0-резюм не спрашивает.
Все — через `TranslateBook`/`Redrive`/`translate()`/`redrive()` над мок-провайдером; отказные
сценарии проверяют не только текст ошибки, но **число вызовов провайдера, `committed`/`reserved` и
неизменность durable-строк**.
### Мутационный рубеж: 19 мутаций в КОПИИ дерева, 19 красных, 0 выживших
| # | Мутация | Красный тест |
|---|---|---|
| M1 | гейт отключён целиком | RefusesOverThreshold, Cap, ReadOnlyPathsAlive |
| M2 | `skipped`-строки считаются | ProjectionExcludesSkipped |
| M3a | edit-строки судятся по драфт-снапшоту | ProjectionIsPerWave, IgnoresRetiredStages |
| M3b | draft-строки судятся по edit-снапшоту | ProjectionIsPerWave, ExcludesSkipped, IgnoresRetired |
| M4 | потолок игнорируется | CapBelowProjectionRefuses |
| M5a | порог `min``max` | ThresholdFormula |
| M5b | флор $0.50 снят при нулевой проекции | ThresholdFormula |
| M5c | `rebill_consent_usd` игнорируется | UnderThreshold, ThresholdFormula |
| M6 | согласие в redrive ПОСЛЕ сброса | RedriveRefusesBeforeDestructiveReset |
| M7a | экстраполяция убрана | ProjectBookUSDExtrapolates |
| M7b | знаменатель `processed``total` | ProjectBookUSDExtrapolates |
| M7c | flagged-единицы вне базы | TestStatusAndRedrive |
| M8 | read-only `status` зовёт гейт | ReadOnlyPathsAlive |
| M9 | гард пробельной формы снят | ParseAcceptRebillSpaceFormRefused |
| M10 | отрицательный потолок принимается | ParseAcceptRebillRejectsBadCeiling |
| M11a | согласие не проводится в раннер (translate) | TranslateWiresAcceptRebill |
| M11b | потолок теряется при проводке | TranslateWiresAcceptRebill |
| M12 | сравнение с потолком ослаблено | CapBelowProjectionRefuses |
| M13 | согласие не проводится (redrive) | RedriveWiresAcceptRebill |
| M14 | `esc_model` обратно = ПОПРОБОВАННАЯ | GoldenDeterminism + EscalationFallbackAlsoFails |
| M15 | `esc_model` не ставится никогда | Golden + FixesEcho + SurvivesResume |
| M16 | строки исчезнувших чанков снова считаются | ProjectionIgnoresVanishedChunks |
| M17 | live-множество инвертировано | RefusesOverThreshold, Cap, IsPerWave |
**Две мутации СНАЧАЛА выжили и вскрыли дыры в моих же тестах** — обе закрыты, а не списаны:
- **M3 (пер-волновая ось)** — первая формулировка мутации была ненаблюдаемой; переформулировал в обе
стороны и обнаружил, что «чистая книга не проецирует пере-оплату» не ассертилось нигде. Добавил
этот ассерт в `TestRebillProjectionIsPerWave` — теперь обе стороны красные.
- **M11 (проводка CLI)** — ни один тест не проверял, что распарсенное согласие ДОЕЗЖАЕТ до раннера.
Это буквально регресс-класс D20.4 («`--resnapshot` парсился и молча игнорировался»), который в
этом репозитории уже случался. Написан `cmd/tmctl/rebill_cli_test.go`, гоняющий настоящие
`translate()`/`redrive()` над мок-провайдером.
---
## Что осталось / вопросы оркестратору
1. **Ратифицировать девиацию от алиас-оговорки §7.3** (решение №3 выше): `--resnapshot` и
`--accept-rebill` ортогональны. Если оркестратор захочет алиас-семантику — это одна строка в
`checkRebillConsent`, но тогда ратифицированный порог становится мёртвым.
2. **Проекция в `tmctl status` НЕ добавлена — сознательно** (скоуп). Спека §9 предлагает заменить
булев `ConfigDrift` числом «N чанков, ~$X»; на снапшотной гранулярности это дёшево и `omitempty`
оставило бы JSON байт-идентичным для недрейфующей книги. Не строил без пинга. Сегодня сумму
узнают из отказа `translate` ($0, без резерваций).
3. **Пункт 3 — слот для лендинга** (см. выше, кандидат (б): ближайший полный `--resnapshot` стенда).
4. **Долг ru-target (слой 7)** не тронут — ждёт своего дизайн-пака, как предписано.
5. **Чужая зона:** за время сессии параллельная сессия тронула `docs/architecture/05-decisions-log.md`,
`docs/experiments/*`, `eval/README.md` и создала `docs/archive/reports/POLYGON_TOS_LABELS_REPORT_2026-07-25.md`.
**Не трогал ничего из перечисленного.** Мои файлы — только `backend/**` + этот отчёт.