From dad4b56e0d8f228bca6149eac50a242b19707858 Mon Sep 17 00:00:00 2001 From: "Claude (backend session)" Date: Sat, 25 Jul 2026 19:12:00 +0300 Subject: [PATCH] Land pack eighteen as D39.31: rebill consent gate before money, authoritative escalation model with machine-verified golden recapture, generality nits deferred by measurement --- CLAUDE.md | 2 +- backend/cmd/tmctl/invocation.go | 70 +- backend/cmd/tmctl/invocation_test.go | 57 ++ backend/cmd/tmctl/main.go | 16 +- backend/cmd/tmctl/rebill_cli_test.go | 193 +++++ backend/internal/config/book.go | 9 + backend/internal/pipeline/bookrun.go | 18 +- backend/internal/pipeline/rebill.go | 235 +++++++ backend/internal/pipeline/rebill_test.go | 660 ++++++++++++++++++ backend/internal/pipeline/runner.go | 5 + .../internal/pipeline/runner_memory_test.go | 4 +- backend/internal/pipeline/runner_test.go | 71 ++ backend/internal/pipeline/stagerun.go | 21 +- backend/internal/pipeline/status.go | 30 +- backend/internal/pipeline/status_test.go | 3 + .../pipeline/testdata/golden/capture.golden | 8 +- backend/internal/pipeline/waverun_test.go | 3 + backend/internal/store/chunkstatus.go | 36 +- docs/PROGRESS.md | 10 +- docs/README.md | 2 +- docs/architecture/05-decisions-log.md | 21 + .../reports/PACK18_DEBTS_REPORT_2026-07-25.md | 348 +++++++++ 22 files changed, 1768 insertions(+), 54 deletions(-) create mode 100644 backend/cmd/tmctl/rebill_cli_test.go create mode 100644 backend/internal/pipeline/rebill.go create mode 100644 backend/internal/pipeline/rebill_test.go create mode 100644 docs/archive/reports/PACK18_DEBTS_REPORT_2026-07-25.md diff --git a/CLAUDE.md b/CLAUDE.md index e9b24cff..cbe3daaf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -14,7 +14,7 @@ Go-бэкенд издательского художественного пер ## Источники истины (по убыванию) -1. **`docs/architecture/05-decisions-log.md` (D1–D39.30)** — ратифицированный контракт; при конфликте с любым другим доком побеждает он; сверху файла — карта актуальности (что чем superseded). **Правило чтения (онбординг-диета 25.07): карта + живая голова (эра D39.x); корпус D1–D38 = справочник — по ссылкам/grep по D-номеру, целиком НЕ читать.** +1. **`docs/architecture/05-decisions-log.md` (D1–D39.31)** — ратифицированный контракт; при конфликте с любым другим доком побеждает он; сверху файла — карта актуальности (что чем superseded). **Правило чтения (онбординг-диета 25.07): карта + живая голова (эра D39.x); корпус D1–D38 = справочник — по ссылкам/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. diff --git a/backend/cmd/tmctl/invocation.go b/backend/cmd/tmctl/invocation.go index 51601def..9c7ff04b 100644 --- a/backend/cmd/tmctl/invocation.go +++ b/backend/cmd/tmctl/invocation.go @@ -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, }, diff --git a/backend/cmd/tmctl/invocation_test.go b/backend/cmd/tmctl/invocation_test.go index b38822cb..75f55d70 100644 --- a/backend/cmd/tmctl/invocation_test.go +++ b/backend/cmd/tmctl/invocation_test.go @@ -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") diff --git a/backend/cmd/tmctl/main.go b/backend/cmd/tmctl/main.go index 3058beca..370dd7f6 100644 --- a/backend/cmd/tmctl/main.go +++ b/backend/cmd/tmctl/main.go @@ -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 { diff --git a/backend/cmd/tmctl/rebill_cli_test.go b/backend/cmd/tmctl/rebill_cli_test.go new file mode 100644 index 00000000..3d4cbbd6 --- /dev/null +++ b/backend/cmd/tmctl/rebill_cli_test.go @@ -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) + } +} diff --git a/backend/internal/config/book.go b/backend/internal/config/book.go index 591bca66..4c3c48e4 100644 --- a/backend/internal/config/book.go +++ b/backend/internal/config/book.go @@ -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 - ")) } diff --git a/backend/internal/pipeline/bookrun.go b/backend/internal/pipeline/bookrun.go index aa166eb3..0d044447 100644 --- a/backend/internal/pipeline/bookrun.go +++ b/backend/internal/pipeline/bookrun.go @@ -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, diff --git a/backend/internal/pipeline/rebill.go b/backend/internal/pipeline/rebill.go new file mode 100644 index 00000000..e3bbf18b --- /dev/null +++ b/backend/internal/pipeline/rebill.go @@ -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=) + 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= to accept it only up to a ceiling (a ceiling below the projection refuses).%s", + proj.Rows, proj.USD, threshold, source, hint) +} diff --git a/backend/internal/pipeline/rebill_test.go b/backend/internal/pipeline/rebill_test.go new file mode 100644 index 00000000..283f94e7 --- /dev/null +++ b/backend/internal/pipeline/rebill_test.go @@ -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) + } +} diff --git a/backend/internal/pipeline/runner.go b/backend/internal/pipeline/runner.go index c6680c70..d5369078 100644 --- a/backend/internal/pipeline/runner.go +++ b/backend/internal/pipeline/runner.go @@ -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 diff --git a/backend/internal/pipeline/runner_memory_test.go b/backend/internal/pipeline/runner_memory_test.go index 0d1d4db8..dd430ad8 100644 --- a/backend/internal/pipeline/runner_memory_test.go +++ b/backend/internal/pipeline/runner_memory_test.go @@ -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) } diff --git a/backend/internal/pipeline/runner_test.go b/backend/internal/pipeline/runner_test.go index 3b014cea..72767cfd 100644 --- a/backend/internal/pipeline/runner_test.go +++ b/backend/internal/pipeline/runner_test.go @@ -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) { diff --git a/backend/internal/pipeline/stagerun.go b/backend/internal/pipeline/stagerun.go index cc249b0d..f04e1649 100644 --- a/backend/internal/pipeline/stagerun.go +++ b/backend/internal/pipeline/stagerun.go @@ -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() diff --git a/backend/internal/pipeline/status.go b/backend/internal/pipeline/status.go index 4c706787..aa01276e 100644 --- a/backend/internal/pipeline/status.go +++ b/backend/internal/pipeline/status.go @@ -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 { diff --git a/backend/internal/pipeline/status_test.go b/backend/internal/pipeline/status_test.go index 204c479a..53ca2bf4 100644 --- a/backend/internal/pipeline/status_test.go +++ b/backend/internal/pipeline/status_test.go @@ -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) diff --git a/backend/internal/pipeline/testdata/golden/capture.golden b/backend/internal/pipeline/testdata/golden/capture.golden index d317e41b..7469c217 100644 --- a/backend/internal/pipeline/testdata/golden/capture.golden +++ b/backend/internal/pipeline/testdata/golden/capture.golden @@ -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="" diff --git a/backend/internal/pipeline/waverun_test.go b/backend/internal/pipeline/waverun_test.go index e448a2fa..d2cdb287 100644 --- a/backend/internal/pipeline/waverun_test.go +++ b/backend/internal/pipeline/waverun_test.go @@ -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) } diff --git a/backend/internal/store/chunkstatus.go b/backend/internal/store/chunkstatus.go index addfd8c6..f3ee252d 100644 --- a/backend/internal/store/chunkstatus.go +++ b/backend/internal/store/chunkstatus.go @@ -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 diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index a451844c..0db8dff1 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -1,11 +1,15 @@ # Журнал прогресса -> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-07-25, пак-17 закрыт, право по ToS добыто и ПОДПИСАНО, D39.26–D39.30). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1–D39.30); этот файл — ЖУРНАЛ.** +> **⟶ ТЕКУЩЕЕ СОСТОЯНИЕ** (на 2026-07-25, пак-17 закрыт, право по ToS подписано, пак-18 принят — долгов контракта нет, D39.26–D39.31). **Источник истины по РЕШЕНИЯМ — `architecture/05-decisions-log.md` (D1–D39.31); этот файл — ЖУРНАЛ.** > - **Фазы/курс:** Ф0 ✅ · Ф1-инфра ✅ (D20–D28; golden = инвариант №8) · арка «качество-первым» D26→D38.5 закрыта · **арх-ресет D39 исполнен ЦЕЛИКОМ** (7 слоёв → программа D39.1–D39.10 → паки 11–16, D39.13–D39.24; статус слоёв — шапка-таблица `architecture/09-target-architecture.md`) · пере-прогон rerun2 прочитан (D39.20: операционка живьём, $0-резюм ×3 арма, $6.63<$15; планка ≤2 НЕ пройдена; анти-корреляция гладкость↔верность ×3 → mistral-редактор вон) · эксп ЗАКРЫТ (D39.22), итерация №2 отложена · **курс = разработка бэкенда: пак-17 «канал B вживую» ЗАКРЫТ ЦЕЛИКОМ (дизайн D39.26 · стройка D39.27 · дельта `allow` D39.28, 25.07); вокабуляр = ПАРА `violence`/`sexually-explicit`, остаток — ФАКТЫ ToS в зоне полигона (fail-closed до них)** (D39.25: в движке НЕТ понятия «18+» — generic content-labels × provider-capabilities; лейблы вне хешей · резолв до валидации · один хоп `chain[0]` · гранулярность = книга) → следующий платный прогон (разом: итерация №2 dspro-vs-glm · замер остатка ремонта → решение о петле D39.24 · живой канал B) → сид-дельта (元 · 赤城 · 学堂家老 · 春秋蝉) → Ф2-механизмы (голос/состояние D21 · native-Gemini судья) · ja→ru §B5 → пилот Ф2.5. Хроника треков A/B, exp15/16 и стройки паков — архивы ниже + D-лог. > - **Стек:** draft deepseek-v4-flash (thinking ON) → **editor deepseek-v4-pro БИЛИНГВ ИНТЕРИМ (D39.22; glm-5 резерв, mistral вон D39.20)**, промпт v3-discourse (P1a+чэнъюй+few_shot-тумблер) → судья gemini-3.1-pro-preview; канал B Mistral+grok; 7 ключей; ~$0.85/ранобэ (D30.4). -> - **ОТКРЫТЫЕ ПЕТЛИ (норма 25.07, перепись 180 инцидентов: класс NEVER_CLOSED больше не копим — каждая петля обязана получить диспозицию решено/отложено-с-записью/отклонено; ведёт оркестратор).** *Владелец:* ~~вокабуляр content-лейблов~~ **РЕШЕНО 25.07: ПАРА `violence`/`sexually-explicit`** (D39.27 п.5 — сохраняет D14 п.1 и dspro-редактора на violence) ⇒ следствие в работе: assert-only `action` (дельта пака-17) · ~~Q2 редактор под лейблом~~ **ОТЛОЖЕН С ЗАПИСЬЮ** (D39.27 п.7: при паре связывает только `sexually-explicit`-книги, которых нет; возвращается вместе с предусловием D22.7 перед первой explicit-книгой; кандидаты — grok-4.3 интерим, glm-5 только после сверки ToS Z.AI по первоисточнику, mistral отвергнут D39.20) · ~~ToS-факты + три решения по ним~~ **ЗАКРЫТЫ 25.07 (D39.29 факты + D39.30 подписи владельца):** лейбл `violence` НЕ заводится, интерпретация «нейтральное изображение ≠ пропаганда» зафиксирована письменно · риск Z.AI (плоский запрет «violent content»; экспозиция состоялась в rerun2 через glm-арм) ПРИНЯТ сознательно с условиями пересмотра · две строки `accepts_labels: [sexually-explicit]` (`xai`, `mistral`) ПОДПИСАНЫ и ПРИМЕНЕНЫ в `models.yaml`. **Остались НЕблокирующие вопросы владельца:** чтение Gemini-оговорки «for example … pornography» (сужение vs иллюстрация) · связывает ли нас OpenAI Sharing & publication policy 2022 (держит обе клетки OpenAI в UNCLEAR) · применимость Ollama ToS §4 к локальному инференсу · различать ли в конфиге причину пустоты (FORBIDDEN vs UNCLEAR) · дата заведения аккаунта OpenAI · ре-чек политик по хешу перед платными прогонами (триггеры: Google ToS 30.07.2026, квартал 25.10.2026). **Продуктовое (Ф3):** Gemini API Additional ToS запрещает клиентов, «likely to be accessed by individuals under 18» — обязательство на конечный продукт, независимо от лейблов · **промпт-слой под лейблом (НОВОЕ, вскрыто вопросом владельца): лейбл сегодня НЕ доезжает до промпта** (чистые данные маршрутизации + load-time гарантия); нужно ли, чтобы переводчик/редактор ЗНАЛИ о свойстве контента — отдельное решение промпт-слоя · мини-каноны сид-дельты — 元-корень (предложение «изначальн-») и 春秋蝉 (подписано «Весенне-осенняя цикада», ждёт подтверждения) · **включение платной петли ремонта — по замеру остатка на пост-профилактическом прогоне (D39.24; заменил mini-live гейт D6)** · ja→ru-реплика §B5 (тайминг) · старые Ф2.5-блокеры (билингв-якорь — D25 п.9 Q1 · контаминация корпуса — D27 п.4 · судья-дублёр D22.6 · планка запуска · publishable/waiver — D25 п.1 · FN-bound L3 — D25 п.4 · юр-пакет · провенанс 12-*-доков · xAI-ключ off перед продом D27 · residual-тачпойнты exp16, не влитые в сид-дельту: мини-голд алиасов · precision@30, `books/gu-zhenren/exp16/`). *Оркестратор:* ~~ратификация дизайна пака-16~~ РЕШЕНО D39.24 (25.07) · фантом-гард D-ссылок в чек-листе лендинга (действует) · ~~doc-sync pass~~ **ИСПОЛНЕН 25.07** (запись ниже: оба puml перерисованы под пост-пак-16 · баннеры на 01/02/03/04/06/07/08/09 · ресёрч-свип 18/18c/19/20/21/22 · README/POLYGON_PACKAGE4 освежены; residual — пинги полигону в его зоне) · ~~приёмка пака-17 фаза 1~~ **РЕШЕНО D39.26 (25.07: принято в редакции §14, три добора приёмки, фаза 2 размечена)** · **`escalation_model` = ОТВЕТИВШАЯ модель (НОВОЕ, вынесено из пака-17): вердикт-изменение** — golden пинит `esc_model` на ОТКАЗАВШЕМ хопе, маскировка не скрывает ⇒ отдельная ратификация с санкционированным пере-капчером · **`--accept-rebill[=usd]` с порогом `min($0.50, 5%×ProjectedBookUSD)` РАТИФИЦИРОВАН D20.2-Q2, но в коде/CLI ОТСУТСТВУЕТ (НОВОЕ)** — живёт только в спеке `backend/docs/D15.2-*.md`; `--resnapshot` пере-пиннит без суммы · **D22.7 (пер-чанковый L3-скрин) — предусловие первой erotica-книги в проде НЕ снято паком-17 (НОВОЕ, держать отдельным гейтом)** · фантом-гард D-ссылок в чек-листе лендинга (действует). *Бэкенд:* ~~пак-17 (фазы 1–2 + дельта)~~ **ЗАКРЫТ ЦЕЛИКОМ 25.07 (D39.26/27/28)** · **ПАК-18 «долги контракта» ВЫДАН 25.07** (`BACKEND_PACK18_DEBTS_SESSION_PROMPT.md`: `--accept-rebill` на снапшотной гранулярности — `guard_hash` в коде нет и не строим · `escalation_model` = авторитетная модель с САНКЦИОНИРОВАННЫМ пере-капчером golden под этот дифф · два нита общности условно-байт-нейтрально) · **ru-target долг (слой 7, `isRuTarget`×11 прод-сайтов · `TokenizeCyrillic`×6 · ключ «ru» в санитайзере) — ЖДЁТ СВОЕГО ДИЗАЙН-ПАКА** (связывает только не-русский таргет, по текущей карте zh/ja/en→ru не срочен) · **не-долги, следующие несущие:** масштаб целой книги (волны/каденс/потолки/ETA на сотнях глав — гоняли максимум 5) · Ф2-механизмы (голос/состояние D21 · native-Gemini судья) · извлечение дискурс-норм/few-shot из ТЕЛ промтов (слой 2, форсинг = ja→ru) · ~~пак-16 ф.2~~ ЗАЛЕНДЕНА 25.07 · per-class исходы ремонта — схемное решение (класс не durable), принимается ВМЕСТЕ с включением петли · ниты общности (владелец 25.07) — **едут хвостом пака-17 фазы 2 УСЛОВНО**: только в байт-нейтральной форме с доказательством исполнением (иначе `CheapGateVersion`/`pack.Version()` двигают снапшот стендовых книг и убивают $0-резюм драфта под замер остатка пака-16): Detail-строки чекеров → шаблоны/цитаты из данных (статический 时辰-текст в generic-файле соврёт второй паре) · множитель ×2 `lintTimeUnits` (`checkers.go:174`) → ключ данных. *Полигон:* ~~пакет 5 (факты ToS + 8 пингов)~~ **ОТРАБОТАН ЦЕЛИКОМ И ПРИНЯТ 25.07 (D39.29); все восемь пингов закрыты; полигон-очереди НЕТ.** Исторический список закрытых пингов: (1) `00-provider-quirks.md:13,73` — катовер deepseek-chat 24.07 ПРОШЁЛ, пост-катовер факт (жив ли алиас, цены) не внесён — live-проба `/models` + вендор-дока; (2) `00-provider-quirks.md:69` — оговорка «mistral-editor-арм до guard» superseded D39.20 (mistral вон из редакторов; rate-limit-цифры оставить — нужны переводчику канала B пака-17); (3) `09-pilot-protocol.md:3` — ⚠-указатель застрял на D31/glm-5: продлить до пост-D39.22 (dspro-интерим · планка ≤2 не пройдена rerun2 · авто-QA слепыми метками D39.20 · стайл-каноны D39.21), тело не трогать; (4) `15-segmentation-empirics.md:6-7` — шапка до сих пор «НИ ОДНОГО платного вызова», а эксп залендён REV.2 (D39.7/D39.8, $12.79) — перевернуть статус-блок; (5) `16-bank-mining.md:3` — дописать финал «ИСПОЛНЕН И ЗАЛЕНДЁН (D39.10); gender-тачпойнт §7 закрыт (D39.19); продолжение — Go-майнер парити EXACT»; (6) `experiments/README.md` — индекс без строк exp15/16 (сверка застряла на D38.2); (7) `eval/README.md` — не тронут с 19.07 (пре-rerun2): освежить статус под пост-D39.22 (эксп закрыт · судейский риг-стандарт D39.7 · экстракция только `tmctl export`). +> - **ОТКРЫТЫЕ ПЕТЛИ (норма 25.07, перепись 180 инцидентов: класс NEVER_CLOSED больше не копим — каждая петля обязана получить диспозицию решено/отложено-с-записью/отклонено; ведёт оркестратор).** *Владелец:* ~~вокабуляр content-лейблов~~ **РЕШЕНО 25.07: ПАРА `violence`/`sexually-explicit`** (D39.27 п.5 — сохраняет D14 п.1 и dspro-редактора на violence) ⇒ следствие в работе: assert-only `action` (дельта пака-17) · ~~Q2 редактор под лейблом~~ **ОТЛОЖЕН С ЗАПИСЬЮ** (D39.27 п.7: при паре связывает только `sexually-explicit`-книги, которых нет; возвращается вместе с предусловием D22.7 перед первой explicit-книгой; кандидаты — grok-4.3 интерим, glm-5 только после сверки ToS Z.AI по первоисточнику, mistral отвергнут D39.20) · ~~ToS-факты + три решения по ним~~ **ЗАКРЫТЫ 25.07 (D39.29 факты + D39.30 подписи владельца):** лейбл `violence` НЕ заводится, интерпретация «нейтральное изображение ≠ пропаганда» зафиксирована письменно · риск Z.AI (плоский запрет «violent content»; экспозиция состоялась в rerun2 через glm-арм) ПРИНЯТ сознательно с условиями пересмотра · две строки `accepts_labels: [sexually-explicit]` (`xai`, `mistral`) ПОДПИСАНЫ и ПРИМЕНЕНЫ в `models.yaml`. **Остались НЕблокирующие вопросы владельца:** чтение Gemini-оговорки «for example … pornography» (сужение vs иллюстрация) · связывает ли нас OpenAI Sharing & publication policy 2022 (держит обе клетки OpenAI в UNCLEAR) · применимость Ollama ToS §4 к локальному инференсу · различать ли в конфиге причину пустоты (FORBIDDEN vs UNCLEAR) · дата заведения аккаунта OpenAI · ре-чек политик по хешу перед платными прогонами (триггеры: Google ToS 30.07.2026, квартал 25.10.2026). **Продуктовое (Ф3):** Gemini API Additional ToS запрещает клиентов, «likely to be accessed by individuals under 18» — обязательство на конечный продукт, независимо от лейблов · **промпт-слой под лейблом (НОВОЕ, вскрыто вопросом владельца): лейбл сегодня НЕ доезжает до промпта** (чистые данные маршрутизации + load-time гарантия); нужно ли, чтобы переводчик/редактор ЗНАЛИ о свойстве контента — отдельное решение промпт-слоя · мини-каноны сид-дельты — 元-корень (предложение «изначальн-») и 春秋蝉 (подписано «Весенне-осенняя цикада», ждёт подтверждения) · **включение платной петли ремонта — по замеру остатка на пост-профилактическом прогоне (D39.24; заменил mini-live гейт D6)** · ja→ru-реплика §B5 (тайминг) · старые Ф2.5-блокеры (билингв-якорь — D25 п.9 Q1 · контаминация корпуса — D27 п.4 · судья-дублёр D22.6 · планка запуска · publishable/waiver — D25 п.1 · FN-bound L3 — D25 п.4 · юр-пакет · провенанс 12-*-доков · xAI-ключ off перед продом D27 · residual-тачпойнты exp16, не влитые в сид-дельту: мини-голд алиасов · precision@30, `books/gu-zhenren/exp16/`). *Оркестратор:* ~~ратификация дизайна пака-16~~ РЕШЕНО D39.24 (25.07) · фантом-гард D-ссылок в чек-листе лендинга (действует) · ~~doc-sync pass~~ **ИСПОЛНЕН 25.07** (запись ниже: оба puml перерисованы под пост-пак-16 · баннеры на 01/02/03/04/06/07/08/09 · ресёрч-свип 18/18c/19/20/21/22 · README/POLYGON_PACKAGE4 освежены; residual — пинги полигону в его зоне) · ~~приёмка пака-17 фаза 1~~ **РЕШЕНО D39.26 (25.07: принято в редакции §14, три добора приёмки, фаза 2 размечена)** · **`escalation_model` = ОТВЕТИВШАЯ модель (НОВОЕ, вынесено из пака-17): вердикт-изменение** — golden пинит `esc_model` на ОТКАЗАВШЕМ хопе, маскировка не скрывает ⇒ отдельная ратификация с санкционированным пере-капчером · **`--accept-rebill[=usd]` с порогом `min($0.50, 5%×ProjectedBookUSD)` РАТИФИЦИРОВАН D20.2-Q2, но в коде/CLI ОТСУТСТВУЕТ (НОВОЕ)** — живёт только в спеке `backend/docs/D15.2-*.md`; `--resnapshot` пере-пиннит без суммы · **D22.7 (пер-чанковый L3-скрин) — предусловие первой erotica-книги в проде НЕ снято паком-17 (НОВОЕ, держать отдельным гейтом)** · фантом-гард D-ссылок в чек-листе лендинга (действует). *Бэкенд:* ~~пак-17 (фазы 1–2 + дельта)~~ **ЗАКРЫТ ЦЕЛИКОМ 25.07 (D39.26/27/28)** · ~~ПАК-18 «долги контракта»~~ **ПРИНЯТ И ЗАЛЕНДЕН 25.07 (D39.31) — долгов контракта не осталось**; выдавался (`BACKEND_PACK18_DEBTS_SESSION_PROMPT.md`: `--accept-rebill` на снапшотной гранулярности — `guard_hash` в коде нет и не строим · `escalation_model` = авторитетная модель с САНКЦИОНИРОВАННЫМ пере-капчером golden под этот дифф · два нита общности условно-байт-нейтрально) · **ru-target долг (слой 7, `isRuTarget`×11 прод-сайтов · `TokenizeCyrillic`×6 · ключ «ru» в санитайзере) — ЖДЁТ СВОЕГО ДИЗАЙН-ПАКА** (связывает только не-русский таргет, по текущей карте zh/ja/en→ru не срочен) · **не-долги, следующие несущие:** масштаб целой книги (волны/каденс/потолки/ETA на сотнях глав — гоняли максимум 5) · Ф2-механизмы (голос/состояние D21 · native-Gemini судья) · извлечение дискурс-норм/few-shot из ТЕЛ промтов (слой 2, форсинг = ja→ru) · ~~пак-16 ф.2~~ ЗАЛЕНДЕНА 25.07 · per-class исходы ремонта — схемное решение (класс не durable), принимается ВМЕСТЕ с включением петли · ниты общности (владелец 25.07) — **ОТЛОЖЕНЫ ПО ЗАМЕРУ (D39.31 п.5): едут на ближайший ПОЛНЫЙ `--resnapshot`**; для Detail-строк байт-нейтральной формы не существует определительно (дефолт шаблона обязан рендерить текущую строку). Прежняя формулировка: только в байт-нейтральной форме с доказательством исполнением (иначе `CheapGateVersion`/`pack.Version()` двигают снапшот стендовых книг и убивают $0-резюм драфта под замер остатка пака-16): Detail-строки чекеров → шаблоны/цитаты из данных (статический 时辰-текст в generic-файле соврёт второй паре) · множитель ×2 `lintTimeUnits` (`checkers.go:174`) → ключ данных. *Полигон:* ~~пакет 5 (факты ToS + 8 пингов)~~ **ОТРАБОТАН ЦЕЛИКОМ И ПРИНЯТ 25.07 (D39.29); все восемь пингов закрыты; полигон-очереди НЕТ.** Исторический список закрытых пингов: (1) `00-provider-quirks.md:13,73` — катовер deepseek-chat 24.07 ПРОШЁЛ, пост-катовер факт (жив ли алиас, цены) не внесён — live-проба `/models` + вендор-дока; (2) `00-provider-quirks.md:69` — оговорка «mistral-editor-арм до guard» superseded D39.20 (mistral вон из редакторов; rate-limit-цифры оставить — нужны переводчику канала B пака-17); (3) `09-pilot-protocol.md:3` — ⚠-указатель застрял на D31/glm-5: продлить до пост-D39.22 (dspro-интерим · планка ≤2 не пройдена rerun2 · авто-QA слепыми метками D39.20 · стайл-каноны D39.21), тело не трогать; (4) `15-segmentation-empirics.md:6-7` — шапка до сих пор «НИ ОДНОГО платного вызова», а эксп залендён REV.2 (D39.7/D39.8, $12.79) — перевернуть статус-блок; (5) `16-bank-mining.md:3` — дописать финал «ИСПОЛНЕН И ЗАЛЕНДЁН (D39.10); gender-тачпойнт §7 закрыт (D39.19); продолжение — Go-майнер парити EXACT»; (6) `experiments/README.md` — индекс без строк exp15/16 (сверка застряла на D38.2); (7) `eval/README.md` — не тронут с 19.07 (пре-rerun2): освежить статус под пост-D39.22 (эксп закрыт · судейский риг-стандарт D39.7 · экстракция только `tmctl export`). > - Архивы хроники: `archive/PROGRESS-2026-07-04-10.md` (D31) · `archive/PROGRESS-2026-07-10-13.md` (D39.6-гигиена) · `archive/PROGRESS-2026-07-13-25.md` (слайс 25.07: стройка паков 11–16, rerun2). Записи ниже — живой хвост. +## Оркестратор №8 — ПАК-18 ПРИНЯТ (D39.31): согласие на пере-оплату построено, `escalation_model` исправлен под машинно-проверенный пере-капчер, ниты отложены ПО ЗАМЕРУ, 25.07 + +Приёмка execute-first. Пере-ранил всё: build/vet чисты · `gofmt` только `internal/llm/llm.go` (воспроизводится на HEAD — оговорка сессии честная) · `-race` 12/12 · shuffle зелёный · парити EXACT · **ценз диффом ИМЁН 455→475, удалено 0**. **Главное — санкционированный пере-капчер golden проверен МАШИННО, а не глазами:** замаскировал `esc_model="…"` в HEAD-версии и рабочей — файлы БАЙТ-ИДЕНТИЧНЫ; `brief_hash`, оба `snapshot_*_payload`, `memory_version` совпадают построчно ⇒ вердикт-изменение не утащило ни одной другой оси, пере-оплаты нет. Сдвинулись ровно 4 строки, все `ch3/0 draft` (кейс «хоп тоже отказал»). Новый baseline — `211c9013…089045d6`. **Мои независимые мутации:** формула порога `min`→`max` → 6 красных (включая оба CLI-провода); проекция считает строки, резюмящиеся за $0 → 3 красных. **Развилка сессии «`--resnapshot` не есть согласие» принята и оказалась даже не девиацией** — спека прямо говорит «отдельный от `--resnapshot`-алиаса» (`D15.2-*.md:590`), а её собственный аргумент сильнее: при алиас-прочтении ратифицированный порог превращается в гейт, который не может выстрелить. **Отсрочка нитов принята как ИЗМЕРЕННАЯ** (`pack.Version()` `a7d4be2c0465`→`133d71e8d132`; стендовая книга объявляет `langpack_root` на репозиторный каталог — проверил), плюс определительный аргумент по Detail-строкам (дефолт шаблона обязан рендерить сегодняшнюю строку ⇒ 时辰-литерал остаётся в Go при любой форме данных). **Своя ошибка, фиксирую:** запись бэкенд-сессии в PROGRESS уехала в мой прошлый коммит `3bc9eb4` — собрал общий файл целиком, пока её текст лежал в дереве; правило уточнено в D39.31 (для ОБЩИХ файлов смотреть `git diff --cached` по СОДЕРЖИМОМУ, а не по списку имён). + ## Оркестратор №8 — РЕШЕНИЯ ВЛАДЕЛЬЦА ПО ФАКТАМ ToS (D39.30): `violence` не заводим + интерпретация подписана, риск Z.AI принят, две строки применены, 25.07 Владелец разобрал три развилки. **(1) `violence`** — лейбл механически не заводить, а подписанное чтение «нейтральное художественное изображение ≠ promoting/inciting/glorifying» зафиксировать письменно в контракте: аудит-след есть, книга едет, обратимость одной правкой данных сохранена. **(2) Z.AI** — риск принят сознательно (прогон исследовательский, на своей книге, без публикации), с записанными условиями пересмотра: публикация/клиентские книги · чужой контент · смена политики Z.AI (пере-проверка 25.10.2026); уточнение по коду: `glm-5.1` в `chains.default` недостижим (single-hop + `budget_usd: 0`), вектор был именно арм-конфиг. **(3) Подпись дана** — `xai` и `mistral` получили `accepts_labels: [sexually-explicit]` в `backend/configs/models.yaml` моими руками (зона бэкенда, но применение подписанных строк — процедура оркестратора, ратифицирована промтом пакета-5). Проверил исполнением: `go test ./internal/config/` зелёный, ни одна книга лейблов не несёт ⇒ применение ничего не запускает и не двигает (строки — данные провайдера, вне хешей и снапшотов). Механизм перестал быть пустым; для первой explicit-книги по-прежнему обязательны D22.7 и выбор редактора под лейблом. @@ -62,7 +66,7 @@ *(Хроника бэкенд-паков 11–16 (19–25.07, вкл. отчёт-запись пака-15) — в [archive/PROGRESS-2026-07-13-25.md](archive/PROGRESS-2026-07-13-25.md); отчёты паков — `archive/reports/`.)* -### Пак-18 «долги контракта» ОТРАБОТАН (сессия сдала, ждёт приёмки), 25.07 +### Пак-18 «долги контракта» ОТРАБОТАН и ПРИНЯТ (D39.31, приёмка оркестратора №8), 25.07 Отчёт: [`archive/reports/PACK18_DEBTS_REPORT_2026-07-25.md`](archive/reports/PACK18_DEBTS_REPORT_2026-07-25.md). **П.1 `--accept-rebill[=usd]` ПОСТРОЕН** на снапшотной гранулярности (`internal/pipeline/rebill.go`): проекция = Σ хранимых `cost_usd` оплаченных строк под устаревшим снапшотом ЕГО ВОЛНЫ, порог `min($0.50, 5%×ProjectedBookUSD)` + флор $0.50 + переопределение книгой `rebill_consent_usd`; гейт стоит ДО денег в `TranslateBook` и ДО деструктивного сброса в `Redrive`; read-only пути ($0-контракт D20.4) не тронуты. `ProjectedBookUSD` вынесен в общий `projectBookUSD` — порог и `status` считают ОДНО число. **Развилка на ратификацию:** оговорка спеки «`--resnapshot` = алиас-синоним согласия» СОЗНАТЕЛЬНО не перенесена — флаги ортогональны (иначе ратифицированный порог = мёртвый код); 4 существующих теста получили строку согласия. Каскад §7.1-бис зафиксирован как ВЫРОЖДЕННЫЙ, ветка `EstimateUSD` — без предмета на этой гранулярности; `guard_hash` не строился. **П.2 `escalation_model` = АВТОРИТЕТНАЯ модель ПОСТРОЕН**; пере-капчер golden `f7641993…` → `211c9013…` проверен машинно: сдвинулись ровно 4 строки `ch3/0 draft`, дельта ТОЛЬКО в колонке `esc_model`, оба волновых снапшота/`brief_hash`/`memory_version` байт-идентичны ⇒ пере-оплаты нет; миграции нет, смешанная семантика записана в доккоммент `store.ChunkStatus`. **П.3 (два нита общности) ОТЛОЖЕН с замером:** одна новая строка в `configs/langpacks/zh-ru/dc-checkers.txt` двигает `pack.Version()` `a7d4be2c0465`→`133d71e8d132` и ОБА волновых снапшота (проба исполнением), а стендовые книги ссылаются ровно на этот каталог (`rerun2/book*.yaml:22-23`) ⇒ байт-нейтральной формы нет; для Detail-строк она нереализуема определительно. Готовый план на ближайший полный `--resnapshot` — в отчёте. **Приёмка:** build/vet чисты · `-race` 12/12 · `-shuffle=on` зелёный · парити EXACT `n=13618 … 0.9655 (56/58)` · ценз диффом ИМЁН 455→475, удалено 0 · **19 мутаций, 19 красных** (две сначала выжили и вскрыли дыры в тестах — обе закрыты, в т.ч. регресс-класс D20.4 «распарсено, но не проведено в раннер»). diff --git a/docs/README.md b/docs/README.md index 7e60a3cf..26617eaf 100644 --- a/docs/README.md +++ b/docs/README.md @@ -11,7 +11,7 @@ ## Структура -- `architecture/` — синтез. **Источник истины по решениям — [`05-decisions-log.md`](architecture/05-decisions-log.md) (D1–D39.30); при конфликте с любым доком он выше.** +- `architecture/` — синтез. **Источник истины по решениям — [`05-decisions-log.md`](architecture/05-decisions-log.md) (D1–D39.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/` — фактура исследований 04–05.07: `01–10` базовые, `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) — **читай баннер прежде содержимого**. diff --git a/docs/architecture/05-decisions-log.md b/docs/architecture/05-decisions-log.md index ff31da1e..f018d867 100644 --- a/docs/architecture/05-decisions-log.md +++ b/docs/architecture/05-decisions-log.md @@ -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). diff --git a/docs/archive/reports/PACK18_DEBTS_REPORT_2026-07-25.md b/docs/archive/reports/PACK18_DEBTS_REPORT_2026-07-25.md new file mode 100644 index 00000000..276d30b3 --- /dev/null +++ b/docs/archive/reports/PACK18_DEBTS_REPORT_2026-07-25.md @@ -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/**` + этот отчёт.