diff --git a/backend/cmd/tmctl/render.go b/backend/cmd/tmctl/render.go index 54a1e6f8..00b69194 100644 --- a/backend/cmd/tmctl/render.go +++ b/backend/cmd/tmctl/render.go @@ -690,8 +690,15 @@ func renderManifest(w io.Writer, m *pipeline.BookManifest, path string, asJSON b // ceiling has to clear before the run can move AT ALL, and an operator reading only the total would // set a ceiling that admits nothing. if p := m.Price; p != nil { - fmt.Fprintf(w, "expected cost: $%.6f for the whole book (%d source chars; $%.6f of it is book-level and paid once)\n", - p.ExpectedUSD, p.SourceChars, p.BookOnceUSD) + // ⚠ THE BOOK-LEVEL PART IS NAMED A CEILING, NOT A FORECAST, and on a short book that is the whole + // of the honesty: the terminology passes are bounded by their configured budgets and their input + // does not exist until the drafts do, so the figure is what the engine will NOT exceed. On a book + // of a few chapters it can dominate the total — an operator reading «expected cost» as a bill + // would think a short book dearer than a long one per chapter, which is backwards (acceptance F3). + fmt.Fprintf(w, "expected cost: $%.6f for the whole book (%d source chars)\n", p.ExpectedUSD, p.SourceChars) + if p.BookOnceUSD > 0 { + fmt.Fprintf(w, " …of which $%.6f is the book-level CEILING of the terminology passes — a bound the run will not exceed, not a forecast; it is charged once per book, so it weighs most on a short one\n", p.BookOnceUSD) + } fmt.Fprintf(w, "smallest workable ceiling: $%.6f — the largest single reservation; under it NOTHING is admitted\n", p.StepMaxUSD) } fmt.Fprintf(w, "written: %s\n", path) diff --git a/backend/cmd/tmctl/render_test.go b/backend/cmd/tmctl/render_test.go index 847a0cac..cfef41ff 100644 --- a/backend/cmd/tmctl/render_test.go +++ b/backend/cmd/tmctl/render_test.go @@ -507,3 +507,57 @@ func TestAnUnknownDriftBasisIsSaidOutLoud(t *testing.T) { t.Fatal("a book whose drift WAS checked and is clean must not carry the unknown caveat") } } + +// TestTheHumanManifestCallsTheBookLevelFigureACeiling pins the one line an operator reads before +// deciding to buy, and it exists because acceptance (F3) found the previous wording claiming the +// opposite of what the number is. +// +// ⛔ THE BOOK-LEVEL PART IS A BOUND, NOT A FORECAST. The terminology passes are limited by their +// configured budgets and their input does not exist until the drafts do, so the engine publishes what it +// will NOT exceed. On a book of a few chapters that figure can dominate the total, and an operator +// reading «$X of it is book-level and paid once» as a bill concludes a short book is dearer per chapter +// than a long one — which is backwards, and is a conclusion drawn from OUR sentence, not from the number. +// +// ⚠ The line is also ABSENT when there is nothing to bound: a configuration with no terminology phase +// prices no book-level spend, and printing «$0.000000 is a ceiling» would invent a phase that never runs. +func TestTheHumanManifestCallsTheBookLevelFigureACeiling(t *testing.T) { + price := &pipeline.BookPrice{ExpectedUSD: 3.5, BookOnceUSD: 2.0, StepMaxUSD: 0.25, SourceChars: 4000} + m := &pipeline.BookManifest{Structure: "detected", Price: price} + + var b bytes.Buffer + if err := renderManifest(&b, m, "book.manifest.json", false); err != nil { + t.Fatalf("renderManifest: %v", err) + } + out := b.String() + + // The total says what it is and nothing more: the old form folded the bound into the same sentence + // as the expectation, which is where the two got confused. + if !strings.Contains(out, "expected cost: $3.500000 for the whole book (4000 source chars)") { + t.Errorf("the total must name itself and the size it was computed from:\n%s", out) + } + if strings.Contains(out, "paid once)") { + t.Errorf("the bound must not ride inside the total's parentheses — that is the wording F3 refused:\n%s", out) + } + // The bound says it is a bound, in the operator's own words rather than by implication. + if !strings.Contains(out, "$2.000000") || !strings.Contains(out, "CEILING") { + t.Errorf("the book-level figure must be named a ceiling:\n%s", out) + } + if !strings.Contains(out, "not a forecast") { + t.Errorf("a reader must not have to infer that a bound is not a bill:\n%s", out) + } + // And the number that decides whether ANY ceiling admits work stays on its own line. + if !strings.Contains(out, "smallest workable ceiling: $0.250000") { + t.Errorf("the largest indivisible reservation must survive the rewording:\n%s", out) + } + + // ⛔ NOTHING TO BOUND ⇒ NOTHING SAID. A run with no terminology phase must not be told about a + // ceiling on passes it will never make. + var b2 bytes.Buffer + m2 := &pipeline.BookManifest{Structure: "none", Price: &pipeline.BookPrice{ExpectedUSD: 1.5, StepMaxUSD: 0.25, SourceChars: 4000}} + if err := renderManifest(&b2, m2, "book.manifest.json", false); err != nil { + t.Fatalf("renderManifest: %v", err) + } + if strings.Contains(b2.String(), "CEILING") { + t.Errorf("a book with no book-level spend must not be told about a ceiling on it:\n%s", b2.String()) + } +} diff --git a/backend/cmd/tmmutate/mutations.json b/backend/cmd/tmmutate/mutations.json index 0ecc2341..d44d65b9 100644 --- a/backend/cmd/tmmutate/mutations.json +++ b/backend/cmd/tmmutate/mutations.json @@ -2047,8 +2047,8 @@ "edits": [ { "file": "internal/membank/memseed.go", - "find": "\t\tfor _, why := range WireUnfitReasons(t.Src, t.Dst) {\n\t\t\tproblems.addKeyed(subjectOf(\"term-wire\", t.Src, t.Sense, t.SinceCh, t.UntilCh),\n\t\t\t\tfmt.Sprintf(\"term %q: %s\", t.Src, why))\n\t\t}\n", - "replace": "" + "find": "\t\tif reasons := WireUnfitReasons(t.Src, t.Dst); len(reasons) > 0 {", + "replace": "\t\tif reasons := WireUnfitReasons(t.Src, t.Dst); false && len(reasons) > 0 {" } ] }, @@ -2282,8 +2282,8 @@ "edits": [ { "file": "internal/pipeline/priceprojection.go", - "find": "\tif sp.carriesSource && inputTokens != sourceTokens {", - "replace": "\tif false && sp.carriesSource && inputTokens != sourceTokens {" + "find": "\tif sp.carriesSource && !inputIsSource {", + "replace": "\tif false && sp.carriesSource && !inputIsSource {" } ] }, @@ -2294,7 +2294,7 @@ "edits": [ { "file": "internal/pipeline/priceprojection.go", - "find": "\tif sp.carriesSource && inputTokens != sourceTokens {", + "find": "\tif sp.carriesSource && !inputIsSource {", "replace": "\tif sp.carriesSource {" } ] @@ -2318,8 +2318,8 @@ "edits": [ { "file": "internal/pipeline/priceprojection.go", - "find": "\tfor _, m := range u.Members {\n\t\tdraftTokens += p.walkDraftCalls(m, func(sp stagePrice, srcTok, in, out int) {", - "replace": "\tfor _, m := range u.Members[:1] {\n\t\tm.Text = src\n\t\tdraftTokens += p.walkDraftCalls(m, func(sp stagePrice, srcTok, in, out int) {" + "find": "\tfor _, m := range u.Members {\n\t\tdraftTokens += p.walkDraftCalls(m, func(sp stagePrice, srcTok, in, out int, inIsSource bool) {", + "replace": "\tfor _, m := range u.Members[:1] {\n\t\tm.Text = src\n\t\tdraftTokens += p.walkDraftCalls(m, func(sp stagePrice, srcTok, in, out int, inIsSource bool) {" } ] }, @@ -2354,8 +2354,8 @@ "edits": [ { "file": "internal/pipeline/priceprojection.go", - "find": "\treturn g.Terminology.BudgetUSD + g.Terminology.ClassifyBudgetUSD", - "replace": "\treturn 0" + "find": "\tusd := g.Terminology.BudgetUSD", + "replace": "\tusd := 0.0" } ] }, @@ -2394,5 +2394,125 @@ "replace": "\t\tin, out = up.PromptTokens, up.PromptTokens" } ] + }, + { + "id": "DF-money-follows-the-ceiling-not-the-outcome", + "why": "acceptance F6/V2-4, found by BOTH verifiers independently: `Finished.money` follows «a ceiling was REACHED», not «the outcome word is ceiling» — the contract file said the latter, and a consumer in another zone building on either reading gets a different bug (money on `stopped`/`failed` is real; `ceiling` without seeded counters carries none)", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/events.go", + "find": "\tif !e.ceilingSaid || e.waves == nil {", + "replace": "\tif e.waves == nil {" + } + ] + }, + { + "id": "DF-old-sidecar-must-not-silence-the-price", + "why": "acceptance V2-3: `price` and `structure` are ADDITIVE so the document version deliberately did not move for them — a sidecar from the previous build passes version, counters and key, comes back «current» carrying no price, and the fallback the code itself calls the invariant never runs. The book then reports NO price at all, silently, on the surface a buyer's platform reads before deciding", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/manifest.go", + "find": "\tif m := r.loadManifest(); m != nil && m.Price != nil && m.Structure != \"\" {", + "replace": "\tif m := r.loadManifest(); m != nil {" + } + ] + }, + { + "id": "DF-engine-written-document-must-not-kill-the-run", + "why": "acceptance F8, widened: the mined delta AND the auto-bank are written by this engine from MODEL OUTPUT, and refusing such a document aborts a PAID run over our own answer while asking a person to hand-edit a file nobody authored. The operator's own seed keeps the refusal — that one is theirs to fix", + "package": "./internal/membank/", + "edits": [ + { + "file": "internal/membank/memseed.go", + "find": "\t\t\tif engineWritten {", + "replace": "\t\t\tif false && engineWritten {" + } + ] + }, + { + "id": "DF-operator-seed-keeps-its-refusal", + "why": "the mirror of the split: dropping a row from the OPERATOR's seed would leave a term they believe is in force silently absent from every request — the refusal is what tells them", + "package": "./internal/membank/", + "edits": [ + { + "file": "internal/membank/memseed.go", + "find": "func ParseBankSeed(name string, raw []byte) (BankSeed, error) {\n\treturn parseBankSeed(name, raw, false)", + "replace": "func ParseBankSeed(name string, raw []byte) (BankSeed, error) {\n\treturn parseBankSeed(name, raw, true)" + } + ] + }, + { + "id": "DF-classifier-budget-only-when-it-runs", + "why": "acceptance V2-5, and it is a class rather than a slip: `classify_types` gates the classifier PHASE, and the loader requires its budget be non-zero ONLY when that toggle is on — so charging the budget unconditionally bills a book for a pass that cannot happen ($1.00 of $2.00 on the shipped arm). ⚠ THE TEST OF THIS FUNCTION PINNED THE WRONG NUMBER: a gate defending the defect, which no review that reads green can catch", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/priceprojection.go", + "find": "\tif g.Terminology.ClassifyTypes {\n\t\tusd += g.Terminology.ClassifyBudgetUSD\n\t}", + "replace": "\tusd += g.Terminology.ClassifyBudgetUSD" + } + ] + }, + { + "id": "DF-input-identity-is-stated-not-counted", + "why": "acceptance V2-8: two token counts can coincide without being the same text — a pair whose fertility sits near 1.0 makes a draft the size of its source — and then a later stage that DOES receive the source stops being charged for it. Identity is a fact the caller has and the arithmetic does not", + "package": "./internal/pipeline/", + "edits": [ + { + "file": "internal/pipeline/priceprojection.go", + "find": "\tif sp.carriesSource && !inputIsSource {", + "replace": "\tif sp.carriesSource && inputTokens != sourceTokens {" + } + ] + }, + { + "id": "DF-declared-means-every-boundary", + "why": "acceptance V2-7: asking only whether a form feed appeared called a fifty-chapter header-matched cut `declared` on the strength of one stray \\f — forty-nine guesses reported as the file's own word, and a consumer offering an order in chapters trusts the wrong thing", + "package": "./internal/chunk/", + "edits": [ + { + "file": "internal/chunk/ingest.go", + "find": "structureOf(chapters, len(parts) > 1 && len(chapters) == len(parts))", + "replace": "structureOf(chapters, len(parts) > 1)" + } + ] + }, + { + "id": "DF-book-level-figure-is-a-forecast", + "why": "acceptance F3: the book-level part of the price is a BOUND the run will not exceed, and calling it money `paid once` in the same breath as the expected total makes a short book read as dearer per chapter than a long one — a wrong conclusion drawn from our sentence rather than from the number", + "package": "./cmd/tmctl/", + "edits": [ + { + "file": "cmd/tmctl/render.go", + "find": "fmt.Fprintf(w, \"expected cost: $%.6f for the whole book (%d source chars)\\n\", p.ExpectedUSD, p.SourceChars)", + "replace": "fmt.Fprintf(w, \"expected cost: $%.6f for the whole book (%d source chars; $%.6f of it is book-level and paid once)\\n\", p.ExpectedUSD, p.SourceChars, p.BookOnceUSD)" + } + ] + }, + { + "id": "DF-no-book-level-spend-says-nothing", + "why": "acceptance F3, the other half: a configuration with no terminology phase prices no book-level spend, and printing a $0.000000 ceiling invents a bound on passes that never run", + "package": "./cmd/tmctl/", + "edits": [ + { + "file": "cmd/tmctl/render.go", + "find": "\t\tif p.BookOnceUSD > 0 {", + "replace": "\t\tif true {" + } + ] + }, + { + "id": "DF-a-dropped-row-is-not-silent", + "why": "the authorship split trades a dead paid run for a dropped row — and a drop nobody can read is the worse half of that trade: a mined term vanishes from every request and an operator asking why has nothing to answer with", + "package": "./internal/membank/", + "edits": [ + { + "file": "internal/membank/memseed.go", + "find": "\t\t\t\tbs.Dropped = append(bs.Dropped, t.Src)\n", + "replace": "" + } + ] } ] diff --git a/backend/docs/DISCLOSURE_LAW_DESIGN.md b/backend/docs/DISCLOSURE_LAW_DESIGN.md index 64591a58..ef4dcb41 100644 --- a/backend/docs/DISCLOSURE_LAW_DESIGN.md +++ b/backend/docs/DISCLOSURE_LAW_DESIGN.md @@ -261,7 +261,7 @@ number up; a class this build has never heard of still lands inside the band and «оплачено впустую»**. На `coldrun-v16` в этих 11 строках ($0.12316089) лежат три РАЗНЫЕ вещи: `degraded=cjk_artifact` у роли **classifier** — 3 строки, $0.00918827, **мис-вердикт**, вызовы удались. ⚠ Обоснование — КОДОМ, а не строкой бэклога 105: её предпосылка «`classify_types` выключен во всех - шиппинг-конфигах» для ЭТОГО прогона ЛОЖНА — он его включал. Механизм прямой: `internal/pipeline/chunkrun.go:40`=`SourceEchoExpected: role == roleTerminologist` + шиппинг-конфигах» для ЭТОГО прогона ЛОЖНА — он его включал. Механизм ТОГДА: исключение только терминологу; СЕГОДНЯ — обоим банк-ролям (`terminologist.go`, `isBankRole`) — исключение из эхо-правила выдано ТОЛЬКО терминологу, хотя формат ответа классификатора — та же двуязычная таблица терминов, а докстринг самого поля объясняет, чем это кончается: *«Left on, every healthy call of that role lands in request_log as ok=0/degraded=cjk_artifact — poisoning the one signal diff --git a/backend/docs/MONEYSTOP_REPORT.md b/backend/docs/MONEYSTOP_REPORT.md index 67fdff38..68c29979 100644 --- a/backend/docs/MONEYSTOP_REPORT.md +++ b/backend/docs/MONEYSTOP_REPORT.md @@ -182,12 +182,64 @@ $ cd <копия HEAD 32be78a>/backend && go test ./internal/membank/ -run 'Test сначала. Для НЕОБЯЗАТЕЛЬНЫХ вызовов вопрос не встаёт вовсе: они на потолке деградируют, а не останавливают книгу, и кадра не рождают. Вывод от этого не меняется, а утверждение перестаёт быть шире того, что проверено. -**Вывод из этого — не «убрать поле», а назвать границу точнее, и три довода проверяемы:** (1) тот же пак -РАТИФИЦИРОВАННО публикует цену вызова на том же шве — `step_max_usd` в `manifest --json` это буквально -«самый крупный неделимый резерв», заказанный строкой 278; значит это ТА ЖЕ дверь, а не новая; (2) ПТ-33 -охраняет СТРУКТУРУ расходов — цену модели, стоимость стадии, разложение вызова, — и ни недостача, ни -`step_max_usd` её не дают; (3) пользовательская поверхность не затронута: кадр идёт по внутреннему -проводу, а платформенная сессия письменно подтвердила, что показывать его не будет и не может. +⛔ **ДОВОД «ТА ЖЕ ДВЕРЬ» БЫЛ ЛОЖЕН, И ЭТО ГЛАВНАЯ ПРАВКА ДОФИКСА (F1 приёмки).** Первая редакция этого +абзаца утверждала: «тот же пак ратифицированно публикует цену вызова на том же шве — `step_max_usd`, +значит дверь та же». **Проверено командой, а не рассуждением:** +``` +$ git grep -lE "StepMax|step_max" HEAD -- platform/ # ПУСТО +$ git grep -c "StepMax" HEAD -- backend/internal/runevents/ # 0 +``` +⇒ `step_max_usd` живёт только в `manifest.go`, `priceprojection.go` и `render.go`; **потребитель его не +читает и на проводе его нет.** + +⛔ **И ЭТА ФОРМУЛИРОВКА, В СВОЮ ОЧЕРЕДЬ, ТОЖЕ ОКАЗАЛАСЬ НЕ ОКОНЧАТЕЛЬНОЙ — 05.09 оркестратор ОТОЗВАЛ +ратификацию до слова владельца, и я проверила его посылки своей рукой прежде, чем править:** +- `docs/product-requirements.md` (ПТ-35): отзывая 05.09 «запрет денег на экране», владелец оставил + остальное дословно — «цены моделей, стоимость стадий **и вызовов**». Сузить это до «пользовательской + поверхности» зона не вправе, и я это сужение приняла. +- `docs/BACKEND_MONEYSTOP_SESSION_PROMPT.md:78` применяет запрет к **ПРОВОДУ**, а не к экрану: «Наружу — + только НЕДОСТАЮЩАЯ сумма; цены моделей, стадий и вызовов не выходят». ⇒ замер верификатора попал не в + реализацию поля, а **в посылку ЗАКАЗА**, а на этот случай норма однозначна: стоп и вопрос владельцу. +- ссылка на ратификацию «D39.106 §2» — овер-атрибуция: `05-decisions-index.md:167` говорит, что D39.106 — + про шов «движок = транзиентный юнит, события = `events.jsonl`», и такой фразы там нет вовсе. + +**Что было сделано с кодом: поведение НЕ тронуто, изменилось только то, что код УТВЕРЖДАЛ о своей +легитимности** — шапка поля была помечена `pending owner`. И к вопросу владельца добавлено то, чего не видел +ни оркестратор, ни я: **тензия живёт ВНУТРИ самого заказа.** Пункт (3) промта запрещает цене вызова +выходить наружу, пункт (4) ТРЕБУЕТ опубликовать `step_max_usd` в `manifest --json` — а это по построению +оценка ОДНОГО вызова, самой дорогой неделимой резервации книги. То есть дверь открывает не недостача +одна: заказ держит обе половины сразу. ⚠ Уточнение, которое сужает вопрос до честного: ни недостача, ни +`step_max_usd` не раскрывают, сколько вызов СТОИЛ, — обе величины суть до-вызовные ОЦЕНКИ, а не +сеттлмент. +✅ **РАЗРЕШЕНО ВЛАДЕЛЬЦЕМ 05.09, ратифицировано `D39.203`** — проверено по телу ноты, а не по пересказу: +`05-decisions-log.md:2395`, строка реестра `05-decisions-index.md:264`. Дословно: «Можно, в форме +„сколько добавить“». **Различение оценка/стоимость вошло ОПОРОЙ решения, а не примечанием:** наружу идёт +до-вызовная ОЦЕНКА (резервация, которую движок просит ПЕРЕД звонком), ПТ-35 запрещает СТОИМОСТЬ и +остаётся в силе НЕ суженным, сеттлмент наружу не идёт ни одной дверью. Обе половины тензии закрыты +разом — снятие недостачи вопроса бы не закрыло, потому что `step_max_usd` остаётся. `pending owner` с +шапки поля снят, вместо него — указатель на ноту. + +⚠ **Владелец выбрал ФОРМУ, которой в коде ещё нет:** `max(shortfall, step_max − headroom)` — «сколько +добавить, чтобы прошёл ЛЮБОЙ следующий вызов». Это **НОВЫЙ ЗАКАЗ, а не дефект здесь**: построенное поле +не ложно, оно У́ЖЕ — говорит, сколько не хватило на ЭТОТ вызов. Состав работы — тот же список, которым я +отвечала на вопрос о цене: новая величина + ПЕРЕИМЕНОВАНИЕ поля (`shortfall_micro_usd` станет ложным +именем) + минор `StreamVersion` 1.3→1.4 + зеркало словаря у платформы + пере-снятие пина белого списка. +Пак лендится КАК ПОСТРОЕН, форма — строкой бэклога (`D39.203` §5–6). +⚠ Довод пришёл ко мне от оркестратора, и я приняла его, не проверив командой, — ошибка чужая по +происхождению и МОЯ по принятию: посылку, на которой стоит утверждение в коде, зона обязана проверять +сама, кем бы она ни была принесена. +⚠ **И утверждение в самом коде было сильнее правды:** `runevents.go` писал «discloses nothing about what +any one call cost». Верификатор опроверг это ЖИВЫМ ЗОНДОМ: `shortfall + ceiling = $0.007016840` против +собственного текста отказа движка `denied estimate=$0.007016` — дельта в микродоллар округления; со +вторым зондом (ненулевой `committed`, который платформа читает кадром `spend`) — $0.0070166 против тех же +$0.007016. Текст исправлен на измеренную правду. + +**Что остаётся закрытым и почему поле всё равно не убирается:** ПТ-33 охраняет СТРУКТУРУ расходов — цену +модели, стоимость стадии, разложение вызова между промптом и генерацией; тотал одного отказанного вызова +не является прайс-листом. Кадр идёт внутреннему потребителю, а не человеку (D39.196 §2а снимает запрет +денег в UI, не трогая ПТ-33), платформенная сессия письменно подтвердила, что показывать его не будет и +не может, а стоп, который не может назвать недостачу, оставляет покупателя с мёртвой книгой и без +следующего шага — то, что и намерено живьём 04.09. ⚠ Ратификационную ноту об этом пишет ОРКЕСТРАТОР — сужение ратифицированной фразы не работа зоны. **Версия шва: `StreamVersion` 1.2 → 1.3** по правилу минора (добавлены поле и структура). Прибито @@ -342,18 +394,26 @@ CJK-ONLY.** Сопоставление заголовков идёт по `lang. ### 6.1. Батарея — ЗЕЛЁНАЯ ``` -$ cd backend && make battery +$ cd backend && make battery # go build · go vet ·+tags live ·+tmvet · golangci-lint · go test -race 0 issues. # golangci-lint -ok textmachine/backend/cmd/tmctl 42.759s -ok textmachine/backend/internal/archguard 78.355s -ok textmachine/backend/internal/pipeline 272.408s -ok textmachine/backend/internal/store 67.691s -…19 пакетов, ни одного FAIL… +ok textmachine/backend/cmd/tmctl 42.422s +ok textmachine/backend/internal/archguard 58.448s +ok textmachine/backend/internal/chunk 1.658s +ok textmachine/backend/internal/membank 31.507s +ok textmachine/backend/internal/pipeline 246.730s +ok textmachine/backend/internal/store 56.399s +…19 пакетов с тестами, ни одного FAIL… MAKE_EXIT=0 --- did NOT run (no stand data; see battery-stand) --- --- SKIP: TestMinerFullBookParity · TestHelperEventsRun · TestHelperKillLoop ``` +⚠ **Это прогон на СДАВАЕМОМ дереве, вместе с дофиксом приёмки — не на приземлённом паке.** Батарея +гонялась заново после КАЖДОЙ волны правок: прежнее «зелено» после правки не значит ничего. Последний +прогон — на ЗАМОРОЖЕННОЙ копии финального дерева, в одном заходе с каталогом; предыдущий пришлось +выбросить целиком, потому что дерево изменилось под ним (записи 50–51), и результат о дереве, которого +больше нет, — не результат. + **19 пакетов ok · 0 упавших тестов · 0 issues линтера · 3 скипа (все — стендовые, требуют данных вне репозитория; ровно те же три, что и до пака).** Сюда входит `go vet ./...`, `go vet -tags live`, оба прохода через СОБСТВЕННЫЙ анализатор зоны (`bin/tmvet`) и `gofmt -l` — именно `tmvet` и поймал у меня @@ -372,14 +432,24 @@ MAKE_EXIT=0 ### 6.2. Дифф `^func Test` — ИСПОЛНЕНИЕМ, не по памяти ``` -$ git diff -U0 -- backend | grep -E '^[+-]func Test' # пусто: ни одна функция не удалена и не переименована -$ git grep -h '^func Test' HEAD -- backend | wc -l # 1172 -$ grep -rh '^func Test' backend --include=*_test.go | wc -l # 1208 +$ git diff -U0 -- backend | grep -E '^-func Test' # пусто: ни одна функция не удалена и не переименована +$ git grep -h '^func Test' <до пака> -- backend | wc -l # 1172 +$ grep -rh '^func Test' backend --include=*_test.go | wc -l # 1213 ``` -**+36 тестовых функций, все в трёх новых файлах** (`wirefence_test.go` 6 · `moneystop_test.go` 16 · -`priceprojection_test.go` 14). Ни одна существующая функция не удалена и не переименована — вывод грепа -пуст, а два протухших теста починены ВНУТРИ тела (§7). +**+41 тестовая функция: 39 в трёх новых файлах** (`wirefence_test.go` 7 · `moneystop_test.go` 17 · +`priceprojection_test.go` 15 — сумма ровно 39) **и две дописаны в существующие**: +`cmd/tmctl/render_test.go` (запись 41) и `internal/chunk/ingest_test.go` (запись 54). Обе ДОБАВЛЯЮТ +функцию, не трогая ни одной существующей. Ни одна существующая функция не удалена и не +переименована — вывод грепа по `-func Test` пуст, а два протухших теста починены ВНУТРИ тела (§7). + +**Дофикс приёмки добавил четыре из этих 40** — их видно тем же диффом против уже приземлённого пака +(`git diff -U0 -- backend | grep -E '^\+func Test'`): `TestASidecarFromAnOlderBuildStillYieldsAPrice` +(запись 44) · `TestAnEngineWrittenDocumentDropsTheRowInsteadOfKillingTheRun` (запись 42) · +`TestTheMoneyLedgerFollowsTheCeilingAndNotTheOutcome` (запись 40) · +`TestTheHumanManifestCallsTheBookLevelFigureACeiling` (запись 41) · +`TestTXTStructureIsDeclaredOnlyWhenTheFormatDrewEVERYBoundary` (запись 54 — дописан ПОСЛЕ полного +прогона каталога, потому что его отсутствие предъявила выжившая посадка). ### 6.3. Что я нашла В СВОЕЙ ЖЕ РАБОТЕ (адверсариальный проход, помощник Fable 5) @@ -425,6 +495,24 @@ $ grep -rh '^func Test' backend --include=*_test.go | wc -l # 1208 | 36 | Именной тест `TestUnderACeilingTheWaveFinishesWhatItAdmitted` под ПАРНОЙ посадкой падает текстом `the stop must be a ceiling halt, got … context canceled` — то есть ровно симптомом до-пакового дефекта: допущенный вызов убит отменой. Проверено прямым исполнением, а не по строке вывода инструмента | ПРЕДЪЯВЛЕНО | | 37 | ⛔ **`MS-optional-call-queues-for-headroom` дала `NOTHING` ВТОРОЙ раз, и теперь по другой причине: посадка краснела ЗАВИСАНИЕМ.** Первая причина была сборкой; после починки посадка собиралась, но под ней опциональный вызов ПАРКОВАЛСЯ на settle, которого в юнит-тесте не бывает, — пакет упирался в таймаут, и каталог печатал «the planting did not build, or the run died», что неотличимо от нагрузки машины. **Восьмой случай того же класса за смену, и снова мой** | ЗАКРЫТО: у ожидания появилась ГРАНИЦА (`context.WithTimeout` 2 с). Предъявлено: под посадкой тест падает за 2,00 с с текстом об опциональном вызове, занявшем деньги обязательного, — вместо таймаута пакета | | 38 | Течь резервации на ветке `json.Marshal(resp.Usage)` (возврат без release и без settle) | ПОЧИНЕНО — см. §8 | +| 39 | ⛔ **Довод «та же дверь» и утверждение в коде «discloses nothing about what any one call cost» — ОБА ЛОЖНЫ.** Довод пришёл от оркестратора и был принят мной без проверки командой; утверждение опровергнуто ЖИВЫМ ЗОНДОМ, а не рассуждением (`shortfall + ceiling − committed − reserved` восстанавливает оценку отказанного вызова с точностью до микродоллара округления) | ПОЧИНЕНО — §3; норма записана: **посылку, на которой стоит утверждение в коде, зона проверяет САМА, кем бы она ни была принесена** (F1 приёмки) | +| 40 | ⛔ **`Finished.Money` документирован правилом присутствия, которого нет:** «present only on `outcome: ceiling`». Движок вешает леджер на ПЯТИ терминальных ветвях — потолок больше не гасит соседей, и прогон, защёлкнувшийся на деньгах, уходит `failed`/`stopped`/`bank_stop`. Потребитель этого файла построил бы ровно обратное: леджер отсутствовал бы именно в тех выходах, ради которых он и нужен | ПОЧИНЕНО — правило переписано в обе стороны (`ceiling` тоже НЕ влечёт поле). ⚠ Нашли ДВА верификатора приёмки НЕЗАВИСИМО — сильнейший сигнал, какой может получить неверная фраза | +| 41 | ⛔ **Книжная часть цены печаталась как ПРОГНОЗ:** «$X of it is book-level and paid once». Терминологические проходы ограничены бюджетами, их вход не существует, пока нет черновиков ⇒ фигура — это ПОТОЛОК, который прогон не превысит. На книге в несколько глав она доминирует, и оператор читал бы короткую книгу как более дорогую за главу, чем длинную | ПОЧИНЕНО — `render.go` называет её ceiling отдельной строкой и печатает только когда она есть (F3 приёмки) | +| 42 | ⛔ **Забор убивал ОПЛАЧЕННЫЙ прогон об СОБСТВЕННЫЙ вывод движка:** намайненная дельта и авто-банк пишутся из ОТВЕТА МОДЕЛИ, а грузились строгим `ParseBankSeed` — одна негодная руна в модельном термине, и `loadMinedDelta`/`loadAutoBank` отдают ошибку наверх, материализация банка превращает её в мёртвый прогон, а человека просят руками править файл, которого он не писал | ПОЧИНЕНО — расщепление по АВТОРСТВУ: `ParseEngineBankSeed` роняет РЯД, `ParseBankSeed` по-прежнему отказывает ДОКУМЕНТУ оператора. Пин `TestAnEngineWrittenDocumentDropsTheRowInsteadOfKillingTheRun`, посадки `DF-input-identity-is-stated-not-counted` сосед. ⚠ Приёмка назвала дельту; авто-банк — тот же класс и ВЕРОЯТНЕЕ (пишется из модели КАЖДЫЙ прогон), поэтому тест утверждает правило, а не вызывающего (F8 приёмки) | +| 43 | ⛔ **История в шапке забора льстила прошлому:** «инвариант держался ПО ПОСТРОЕНИЮ, пока у банка не появился внешний писатель». Ложно: намайненный термин — не текст постороннего, но и не наш, это ВЫВОД МОДЕЛИ, и он попадает в тот же system-блок. Недоверенный писатель пришёл с майнингом, а дверь коррекций лишь сделала его ЧЕЛОВЕКОМ | ПОЧИНЕНО в тексте (код покрывает оба по построению). ⚠ Ценность правки не в коде: история, говорящая «раньше это было невозможно», зовёт следующего читателя доверять НЕ ТОЙ границе (F9 приёмки) | +| 44 | ⛔ **У сайдкара ТРИ состояния, а комментарий и код знали ДВА:** отсутствует · протух · АКТУАЛЕН И СТАРШЕ ЭТИХ ПОЛЕЙ. `price`/`structure` аддитивны, версия документа намеренно не двигалась ⇒ файл прежней сборки проходит версию, `selfConsistent` и ключ валидности, возвращается «актуальным» и цены не несёт. Ветка выше отдавала `nil`, фолбэк — тот самый, который комментарий называет инвариантом, — НЕ РАБОТАЛ, и книга молча отчитывалась без цены ровно на поверхности, которую платформа читает ПЕРЕД покупкой | ПОЧИНЕНО — живость аддитивного поля спрашивается О ПОЛЕ, а не о документе (V2-3 приёмки) | +| 45 | ⛔ **Книга платила за проход, которого не бывает:** `bookOnceUSD` складывал `classify_budget_usd` безусловно, тогда как `classify_types` гейтит саму фазу, а загрузчик требует положительный бюджет ТОЛЬКО при включённом тумблере — на поставляемой ветке это ПОЛОВИНА книжной фигуры ($1.00 из $2.00). ⚠ **И ТЕСТ ЭТОЙ ФУНКЦИИ ПИНИЛ НЕВЕРНОЕ ЧИСЛО** — гейт, защищающий дефект: ревью, читающее зелень, такого не ловит НИКОГДА | ПОЧИНЕНО, тест пере-пинен (V2-5 приёмки) | +| 46 | ⛔ **«Формат сам провёл границы» значило ВСЕ границы, а спрашивалось про ОДНУ:** структура называлась `declared`, если в файле встретился хоть один form feed — книга с пятьюдесятью главами, найденными по заголовкам, и одним случайным `\f` отчитывалась словом файла о сорока девяти ДОГАДКАХ | ПОЧИНЕНО — честная проверка в том, добавил ли путь заголовков хоть что-то: `len(parts) > 1 && len(chapters) == len(parts)` (V2-7 приёмки) | +| 47 | ⛔ **Тождество выводилось из РАВЕНСТВА ЧИСЕЛ:** `promptTokens` спрашивал `inputTokens != sourceTokens`, чтобы не зарядить исходник дважды. Два счётчика совпадают, не будучи одним текстом — пара с фертильностью около 1.0 даёт черновик размером с исходник, и стадия, которая исходник ПОЛУЧАЕТ, молча переставала за него платить | ПОЧИНЕНО — идентичность ОБЪЯВЛЯЕТ вызывающий (`inputIsSource bool`), арифметика её не знает; посадка `DF-input-identity-is-stated-not-counted` (V2-8 приёмки) | +| 48 | Ссылка на идентификатор `SourceEchoExpected`, которого в Go больше нет, и на причину, которая с тех пор изменилась (эхо-исключение сегодня покрывает ОБА банк-роля) — три носителя: `paidtail.go`, `MONEY_HONESTY_REPORT.md`, `DISCLOSURE_LAW_DESIGN.md` | ПОЧИНЕНО с НУЛЕВЫМ сдвигом строк (в эти файлы целятся ЧУЖИЕ якоря по номерам — сдвинь я строку, починил бы два своих носителя, сломав пять чужих якорей). ⚠ Носитель в `paidtail.go` якорным линтом не ловится и не поймается впредь | +| 49 | ⛔ **ЧЕТЫРЕ ПОСАДКИ ПРОТУХЛИ ОТ МОЕГО ЖЕ ДОФИКСА** (`promptTokens` получил параметр, колбэк `walkDraftCalls` — пятый аргумент, `bookOnceUSD` — гейт): `PP-source-rides-every-stage-that-asks`, `MS-source-double-charged`, `PP-draft-priced-per-unit-not-per-chunk`, `PP-book-once-charge`. Ни один гейт этого не ловит: нерезолвящийся якорь — не красная посадка, а ОТСУТСТВУЮЩАЯ | ПОЧИНЕНО — пере-нацелены; проверка гоняется после КАЖДОГО касания кода. Это уже ЧЕТВЁРТЫЙ раз за смену, и он подтверждает: класс не в невнимательности, а в том, что каталог — код БЕЗ КОМПИЛЯТОРА | +| 50 | ⛔ **«ЭТО НЕЧЕМ ПОКРЫТЬ» — И ЭТО БЫЛА ОТГОВОРКА.** Правку F3 (`render.go`) я СНАЧАЛА ОБЪЯВИЛА в отчёте непокрываемой: «носитель — текст, красить нечем, проверено чтением». Держалось ровно до попытки: `renderManifest` принимает `io.Writer` и манифест, тест пишется в двадцать строк без стенда и без денег — а на эту функцию не было ни одного теста ВООБЩЕ | ПОЧИНЕНО — `TestTheHumanManifestCallsTheBookLevelFigureACeiling` + две посадки (сказать неправду · сказать лишнее). Класс записан как есть: **«нечем покрыть» — самая дешёвая из неправд, какие смена пишет о себе, и от честной её отличает ОДНА ПОПЫТКА** | +| 51 | ⛔ **МОЙ СОБСТВЕННЫЙ ДОФИКС ПРОМЕНЯЛ ГРОМКУЮ СМЕРТЬ НА ТИХОЕ ИСЧЕЗНОВЕНИЕ.** Расщепление по авторству (запись 42) роняет негодный ряд вместо отказа документу — и роняло МОЛЧА: намайненный термин пропадал из всех запросов без следа, а оператор, видящий его в файле и не видящий в переводе, не имел ЧЕГО прочитать. Это ровно тот класс, о котором предупреждает шапка CLAUDE.md: обход тихо становится нормой | ПОЧИНЕНО — `BankSeed.Dropped` выносит имена наверх (загрузчик логгера не имеет и не должен), `Runner.warnDroppedRows` говорит рядом с уже существующим предупреждением о ДЕКЛИНЕ; пин расширен, посадка `DF-a-dropped-row-is-not-silent`. ⚠ Нашла своим же чтением дофикса, а не приёмкой: **починка, у которой обе половины не проверены, — половина починки** | +| 52 | ⛔ **ЧИСЛО В КОММЕНТАРИИ, КОТОРОГО НИКТО НЕ МЕРИЛ.** Комментарий записи 45 утверждал: «на поставляемой ветке это ПОЛОВИНА книжной фигуры ($1.00 из $2.00)». Проверено командой: `configs/pipeline-c1.yaml:170` = `classify_types: true` ⇒ на поставляемом конфиге фаза ИСПОЛНЯЕТСЯ, бюджет тратится по-настоящему, и дефект там не проявляется вовсе | ПОЧИНЕНО — фраза снята, вместо неё сказано, ГДЕ дефект кусает: конфиг с ВЫКЛЮЧЕННЫМ тоглом и оставшимся от прежней редакции бюджетом (загрузчик перестаёт валидировать ключ ровно тогда, когда фаза выключена). Дефект настоящий, ветка — нет. ⚠ Пришло от оркестратора через внешнего ревьюера; проверила своей рукой прежде, чем править (норма записи 39) | +| 53 | ⛔ **РАТИФИКАЦИЯ, НА КОТОРУЮ ОПИРАЛОСЬ УТВЕРЖДЕНИЕ В КОДЕ, ОТОЗВАНА — а само утверждение было сужением ЧУЖОГО решения.** ПТ-35 — решение владельца, и 05.09, снимая «деньги на экране», он оставил дословно «цены моделей, стоимость стадий И ВЫЗОВОВ»; промт пака применяет запрет к ПРОВОДУ (строка 78), а не к экрану ⇒ живой замер верификатора попал в ПОСЫЛКУ ЗАКАЗА, а не в реализацию. Плюс овер-атрибуция: цитируемой ратификации «D39.106 §2» в журнале нет (D39.106 — про транзиентный юнит и `events.jsonl`) | ЗАКРЫТО: сперва помечено `pending owner` (поведение не тронуто), затем РАТИФИЦИРОВАНО владельцем — `D39.203`, «Можно, в форме „сколько добавить“»; шапка поля указывает на ноту, различение оценка/стоимость стало опорой решения. К вопросу владельца добавлено то, чего не видели ни оркестратор, ни я: **тензия внутри самого заказа** — п.(3) запрещает цене вызова выходить, п.(4) требует опубликовать `step_max_usd`, оценку САМОГО ДОРОГОГО одиночного вызова. ⚠ И обе величины — до-вызовные ОЦЕНКИ, а не сеттлмент: что вызов СТОИЛ, не раскрывает ни одна | +| 54 | ⛔ **ПОЧИНКУ V2-7 НЕ ДЕРЖАЛ НИ ОДИН ТЕСТ — предъявила ВЫЖИВШАЯ ПОСАДКА, а не чтение.** В `internal/chunk/*_test.go` слова `Structure` не было вовсе; два случая в `priceprojection_test.go:144-146` — КРАЙНИЕ (только form feed → `declared`, только заголовки → `detected`), а СМЕШАННОГО — одного случайного `\f` среди найденных заголовками глав — не держал никто. Ровно в нём дефект и жил | ПОЧИНЕНО — `TestTXTStructureIsDeclaredOnlyWhenTheFormatDrewEVERYBoundary` (вся линейка из трёх), посадка красна именно подтестом `one_stray_form_feed_among_matched_headers`. ⚠ Класс: **починка, у которой есть посадка, но нет теста, выглядит как покрытая ровно до полного прогона каталога** | +| 55 | ⛔ **МОЯ СОБСТВЕННАЯ ПРОВЕРКА «отличаются только комментариями» ДАЛА ЛОЖНУЮ ЗЕЛЕНЬ.** Утилита-стриппер не собралась (`error obtaining VCS status`), оба выхода вышли ПУСТЫМИ, `diff` сравнил пустоту с пустотой и напечатал «ИДЕНТИЧНО» — и я чуть не оперлась на это, доказывая законность склейки двух прогонов | ПОЧИНЕНО — размер выхода печатается рядом с вердиктом, пустой выход стал отдельной красной ветвью; результат пере-снят и верен (5020/5020 и 2710/2710 байт). ⚠ Тот же класс, что §6.4, но теперь на ИНСТРУМЕНТЕ ПРОВЕРКИ: **проверка, которая не умеет провалиться, ничего не проверяет** | +| 56 | ⛔ **ДВА КОММЕНТАРИЯ ОДНОГО КОММИТА ПРОТИВОРЕЧИЛИ ДРУГ ДРУГУ О ФОЛБЭКЕ ЦЕНЫ (F7 приёмки).** `manifest.go` объявлял фолбэк ИНВАРИАНТОМ («без него книга без сайдкара молча отчитывается без цены»), а `status.go` в шапке того же поля утверждал обратное: «absent when there is no current manifest — у фолбэка есть текст, но нет гарантии сайдкара, что он описывает ЭТУ резку». Второе описывало код ровно до того момента, как ТОТ ЖЕ коммит добавил фолбэк и сюда не вернулся. Потребитель построил бы «нет сайдкара ⇒ нет цены» и показал бы покупателю пустоту на той самой поверхности, с которой тот и спрашивает | ПОЧИНЕНО — шапка `StatusReport.Price` пере-написана: цена есть и БЕЗ сайдкара; совпадение двух поверхностей держится тем, что оба конца гоняют ОДНУ деривацию (`readModelPrice` → `projectBook`), а не тем, что один отказывается отвечать; и фолбэк описывает эту резку ПО ПОСТРОЕНИЮ, а не по гарантии. ⚠ Правка комментарная, предъявлено машинно: `status.go` без комментариев побайтно идентичен (20012 vs 20012) | ### 6.4. ⛔ ЧЕТВЁРТЫЙ КЛАСС ЛОЖНОЙ ЗЕЛЕНИ, найденный в этой смене @@ -484,41 +572,93 @@ $ grep -rh '^func Test' backend --include=*_test.go | wc -l # 1208 который покраснел, краснеет ПО ТОЙ ПРИЧИНЕ; прежде чем поверить `SURVIVED` — что посадка вообще меняет поведение; а `NOTHING` и таймаут читать как «инструмент не ответил», а не как «дефекта нет». -### 6.5. Каталог мутаций — ПРОГНАН ЦЕЛИКОМ И СОШЁЛСЯ +### 6.5. Каталог мутаций — ПРОГНАН ЦЕЛИКОМ, И НЕ СОШЁЛСЯ С ПЕРВОГО РАЗА ``` -$ cp -a backend /tmp/mut/backend && cd /tmp/mut/backend +$ cp -a backend /tmp/mutF/backend && cd /tmp/mutF/backend $ GOFLAGS=-buildvcs=false go run ./cmd/tmmutate -root . -191 mutation(s) run, 0 unexpected outcome(s) -MUT_EXIT=0 +201 mutation(s) run, 1 unexpected outcome(s) +tmmutate: not what the catalogue records: DF-declared-means-every-boundary +MUT_EXIT=1 ``` -**191 запись · 187 RED · 0 SURVIVED · 0 NOTHING · 0 ROTTED · 0 неожиданных исходов.** -187 + 4 записанных `survives` = 191, арифметика сходится. Записанные выжившие — `G-byte-gate` и +**201 запись · 196 RED · 4 записанных `survives` · 1 НЕОЖИДАННЫЙ ВЫЖИВШИЙ · 0 NOTHING · 0 ROTTED.** +Арифметика сходится: 196 + 4 + 1 = 201. + +⛔ **ВЫЖИЛА ПОСАДКА `DF-declared-means-every-boundary` — И ЭТО НАСТОЯЩАЯ НАХОДКА, А НЕ ШУМ.** Она +восстанавливает дефект `V2-7` (структура зовётся `declared`, если form feed просто ВСТРЕТИЛСЯ), и её +выживание означает ровно одно: **починку V2-7 не держал НИ ОДИН тест.** В `internal/chunk/*_test.go` слово +`Structure` не встречалось вовсе, а два случая, которые есть в `priceprojection_test.go:144-146`, — +крайние (только form feed → `declared`, только заголовки → `detected`). **СМЕШАННОГО случая — одного +случайного `\f` среди найденных заголовками глав — не держал никто, а он и есть тот, в котором дефект +жил.** Дописан `TestTXTStructureIsDeclaredOnlyWhenTheFormatDrewEVERYBoundary` (три случая, вся линейка), и +посадка предъявлена КРАСНОЙ на исправленном дереве — краснеет именно подтест +`one_stray_form_feed_among_matched_headers`. + +**На сдаваемом дереве: 201 запись · 197 RED · 4 записанных выживших · 0 неожиданных.** + +⚠ **И ЭТО СКЛЕЙКА ИЗ ДВУХ ПРОГОНОВ — говорю прямо, потому что «прогнано целиком» звучит сильнее.** +Полный проход шёл на замороженной копии; после него дерево изменилось трижды (тест выше плюс две +КОММЕНТАРНЫЕ правки по отозванной ратификации, записи 52–53). Что склейка законна, **предъявлено +механически, а не рассуждением**: +``` +$ diff -rq /tmp/mutF/backend backend # различаются РОВНО четыре файла: + docs/MONEYSTOP_REPORT.md · internal/chunk/ingest_test.go + internal/pipeline/priceprojection.go · internal/runevents/runevents.go +# каждый .go пере-печатан из AST, разобранного БЕЗ ParseComments: +$ strip /internal/pipeline/priceprojection.go | diff - <(strip backend/…) # ИДЕНТИЧНО (5020 vs 5020 байт) +$ strip /internal/runevents/runevents.go | diff - <(strip backend/…) # ИДЕНТИЧНО (2710 vs 2710 байт) +``` +⇒ два файла отличаются ТОЛЬКО комментариями (исполняемого не изменилось ничего), отчёт не компилируется, +а новый тест живёт в `./internal/chunk/`, где посадка ровно ОДНА — та самая, что и пере-прогнана. То есть +пере-считано в точности то множество, чей вердикт мог измениться, и ни одной записи не унаследовано от +дерева, которого больше нет. + +⚠ **И проверка «отличаются только комментариями» с первого раза была ЛОЖНО ЗЕЛЁНОЙ:** утилита не +собралась (`error obtaining VCS status`), оба выхода вышли ПУСТЫМИ, и `diff` сравнил пустоту с пустотой, +напечатав «ИДЕНТИЧНО». Поймано тем, что размер выхода печатается рядом с вердиктом; в переделанной +проверке пустой выход — отдельная красная ветка. **Проверка, которая не умеет провалиться, ничего не +проверяет** — тот же класс, что и §6.4, теперь на моём собственном инструменте проверки. + +Записанные выжившие — четверо, и каждый с аргументом в `why`: `G-byte-gate` и `WB62-innocent-const-must-not-be-accused` (чужие, аргументированы прежними паками) плюс мои два: · **`MS-reserve-under-the-gate`** — гонка «Reserve закоммичен, счётчик ещё нет» структурна, но воспроизводима только с хуком в планировщик, которого в репозитории нет; в `why` записано ПРЯМО, чего - эта зелень НЕ значит: «не «гарантия держит», а «инструментом не проверяемо»». + эта зелень НЕ значит: «не „гарантия держит“, а „инструментом не проверяемо“». · **`MS-reservation-released-on-marshal-failure`** — ветка недостижима по контракту `encoding/json` (структура из `int` не может не смаршалиться), и покраснить её нечем без подмены стандартной библиотеки. -**Моих записей 33, из них 31 RED и 2 аргументированных выживших.** +**Моих записей 43, из них 41 RED и 2 аргументированных выживших.** -⚠ **И ЭТО ВТОРОЙ ПОЛНЫЙ ПРОГОН. ПЕРВЫЙ БЫЛ ЧЕСТНЫМ, ПОЛНЫМ — И НЕ СОШЁЛСЯ:** `183 RED · 3 SURVIVED · -1 NOTHING`, четыре неожиданных исхода, все мои, включая посадку, красноту которой промт назвал -ОБЯЗАТЕЛЬНЫМ УСЛОВИЕМ ПРИЁМКИ (записи 30–34, 37 в §6.3). Разница между этими двумя прогонами — и есть -норма, которую смена купила: **«каталог прогнан целиком» и «каталог прогнан целиком И СОШЁЛСЯ» — разные -утверждения, и первое звучит как второе.** +⚠ **И ЭТО ТРЕТИЙ ПОЛНЫЙ ПРОГОН ЗА СМЕНУ. ПЕРВЫЙ БЫЛ ЧЕСТНЫМ, ПОЛНЫМ — И НЕ СОШЁЛСЯ:** `183 RED · +3 SURVIVED · 1 NOTHING`, четыре неожиданных исхода, все мои, включая посадку, красноту которой промт +назвал ОБЯЗАТЕЛЬНЫМ УСЛОВИЕМ ПРИЁМКИ (записи 30–34, 37). Второй сошёлся (191/187). Третий, вот этот, — +снова НЕТ. Норма, которую смена купила трижды: **«каталог прогнан целиком» и «каталог прогнан целиком И +СОШЁЛСЯ» — разные утверждения, и первое звучит как второе.** --- ### 6.6. Файлы **Новые:** `internal/membank/{wirefence,wirefence_test}.go` · `internal/pipeline/{reservegate,priceprojection,priceprojection_test,moneystop_test}.go` · `docs/MONEYSTOP_REPORT.md`. -**Изменены:** `internal/membank/{memory,decisions,memseed}.go` · `internal/chunk/ingest.go` · -`internal/pipeline/{waverun,stagerun,escalation,repair,terminologist,events,manifest,status,runner,bookrun}.go` · -`internal/runevents/runevents.go` · `cmd/tmctl/{main,render}.go` · `cmd/tmmutate/mutations.json` (158 → 191) · -тесты `internal/pipeline/{runevents_test,wavepanic_test,volumepanic_test,contractblockers_test}.go`. +**Изменены:** `internal/membank/{memory,decisions,memseed,memvoice}.go` · `internal/chunk/ingest.go` · +`internal/pipeline/{waverun,stagerun,escalation,repair,terminologist,events,manifest,status,runner,bookrun,mining,bankdecisions,paidtail}.go` · +`internal/runevents/runevents.go` · `cmd/tmctl/{main,render}.go` · `cmd/tmmutate/mutations.json` (158 → 201) · +тесты `internal/pipeline/{runevents_test,wavepanic_test,volumepanic_test,contractblockers_test}.go` и +`cmd/tmctl/render_test.go` и `internal/chunk/ingest_test.go` (два существующих тестовых файла, в каждый +ДОБАВЛЕНА функция — §6.2) · +доки зоны `docs/{MONEY_HONESTY_REPORT,DISCLOSURE_LAW_DESIGN}.md` (снятая ссылка, запись 48). + +⚠ **`git status` этого дерева показывает ещё файлы ВНЕ моей зоны, и это АРТЕФАКТ ОТЦЕПЛЕННОГО HEAD, а не +чья-то незакоммиченная работа.** Верификатор приёмки сделал `checkout` в общем дереве, поэтому HEAD стоит +на `9cac062`, а ветка `main` ушла вперёд. Следствие: `docs/architecture/{15-money-path,17-seam-inbound-law}.md` +(пере-нацеливание якорей оркестратора — `stagerun.go:496→495`, `525→559`, `status.go:835→858`, +`events.go:404→409`, уехавших от моего же диффа) числятся «изменёнными» относительно отцепленной линии, +хотя их содержимое уже РАВНО `main` — проверено: `git diff --stat main -- docs/architecture/` пусто. +⚠ И `docs/PROGRESS.md` в этом дереве, наоборот, СТАРШЕ `main` на две строки — то есть коммит этого пути +отсюда откатил бы чужую правку. Не трогала ни одного из них; лендить их из этого дерева НЕЛЬЗЯ. +**Моя работа не затронута:** `backend/` между `81a89e9` и `main` идентичен (`git diff --stat 81a89e9 main -- backend` пусто), +поэтому все диффы этого отчёта против `HEAD` для `backend/` верны. ### 6.7. Команды приёмки — каждое число выше получено одной из них @@ -537,10 +677,18 @@ cd /tmp/headtree/backend && GOFLAGS=-buildvcs=false go test ./internal/membank/ cp -a backend /tmp/mut/backend && cd /tmp/mut/backend && GOFLAGS=-buildvcs=false go run ./cmd/tmmutate -root . # дифф тестов ИСПОЛНЕНИЕМ -git diff -U0 -- backend | grep -E '^[+-]func Test' # пусто: ничего не удалено и не переименовано -git grep -h '^func Test' HEAD -- backend | wc -l # 1172 -grep -rh '^func Test' backend --include=*_test.go | wc -l # 1208 (+36) -python3 -c "import json;print(len(json.load(open('backend/cmd/tmmutate/mutations.json'))))" # 191 (158 + 33) +git diff -U0 -- backend | grep -E '^-func Test' # пусто: ничего не удалено и не переименовано +grep -rh '^func Test' backend --include=*_test.go | wc -l # 1213 (+41 к 1172 до пака) +python3 -c "import json;print(len(json.load(open('backend/cmd/tmmutate/mutations.json'))))" # 201 (158 + 43) + +# посадки дофикса (пункт 1 приёмки + записи 42/47) +go test ./internal/membank/ -run 'TestAnEngineWrittenDocument' -v +go test ./internal/pipeline/ -run 'TestASidecarFromAnOlderBuild|TestTheMoneyLedgerFollowsTheCeiling' -v +go test ./cmd/tmctl/ -run 'TestTheHumanManifestCallsTheBookLevelFigureACeiling' -v +go test ./internal/chunk/ -run 'TestTXTStructureIsDeclaredOnly' -v + +# ⛔ ЯКОРЯ ПОСАДОК — после КАЖДОГО касания кода, а не перед сдачей (запись 49): +# каждая пара (file, find) обязана встречаться в дереве РОВНО ОДИН раз; иначе посадка не красная, а отсутствующая ``` ## 7. Правки существующих тестов — объявление по D39.183 @@ -656,7 +804,61 @@ python3 -c "import json;print(len(json.load(open('backend/cmd/tmmutate/mutations 12. ⚠ **Взаимодействие с `--max-units` под денежным стопом проверено только чтением.** `res.Volume` при денежном стопе не прикладывается, `scope.reconcile` не зовётся; что объёмный грант и денежный потолок в одном прогоне ведут себя корректно во ВСЕХ комбинациях — не предъявлено прогоном. +14. ⚠ **ПРАВКА V2-7 НЕ ДОТЯГИВАЕТСЯ ДО УЖЕ ЗАПИСАННОГО САЙДКАРА, и я это называю, а не чиню.** + `structure` вычисляется при РЕЗКЕ и складывается в сайдкар; правило, по которому `declared` теперь + требует, чтобы формат провёл ВСЕ границы, применяется к новым резкам. Файл, нарезанный сборкой + между `81a89e9` и дофиксом, несёт `declared`, посчитанный СТАРЫМ правилом, проходит версию (поле + аддитивно, версия намеренно не двигалась — см. запись 44) и отдаётся читателю как есть. Лечение — + двинуть `manifestVersion`, то есть выбросить каждый сохранённый сайдкар и пере-резать каждую книгу; + цена несоразмерна окну в несколько часов на движке, который ещё не отгружен. ⚠ Но правило общее и + его стоит держать: **аддитивное поле, чьё ПРАВИЛО потом меняется, перестаёт быть аддитивным** — с + этого момента у него есть версия, просто ненаписанная. +15. ⚠ **Что предупреждение о дропнутом ряде (запись 51) кто-то ПРОЧИТАЕТ — не предъявлено.** Оно уходит + в `r.Log.Warn` рядом с предупреждением о ДЕКЛИНЕ, то есть ровно туда же, куда движок уже говорит о + похожем событии; но доходит ли этот уровень до оператора платформы или тонет в логе прогона — + вопрос чужой зоны, и я туда не ходила. 13. ⚠ **Каталог мутаций гонялся ЦЕЛИКОМ только на финальном дереве.** Промежуточные прогоны я дважды останавливала САМА, обнаружив, что копия устарела от собственных правок: результат о дереве, которого больше нет, — это ровно тот класс, за который смена расплачивалась («утверждение о дереве живёт до следующего коммита»). Цвета в §6.5 — с последнего, замороженного прогона. + +--- + +## 10. Дофикс приёмки — дельта против уже приземлённого пака + +Пак приземлён двумя коммитами (`81a89e9` — дерево, `9cac062` — правка довода в шапке поля). Всё +перечисленное ниже лежит В ДЕРЕВЕ ПОВЕРХ них и не закоммичено: лендит оркестратор. + +| приёмка | что было не так | где починено | +|---|---|---| +| F1 | довод «та же дверь» ложен, и утверждение в коде сильнее правды | `runevents.go`, §3, запись 39 | +| — | `Finished.Money` документирован правилом присутствия, которого нет (нашли ДВА верификатора независимо) | `runevents.go`, пин `TestTheMoneyLedgerFollowsTheCeilingAndNotTheOutcome`, запись 40 | +| F3 | книжная фигура печаталась как прогноз, а она ПОТОЛОК | `cmd/tmctl/render.go`, пин `TestTheHumanManifestCallsTheBookLevelFigureACeiling`, запись 41 | +| F8 | забор убивал оплаченный прогон об собственный вывод движка | расщепление по авторству: `memseed.go`/`memvoice.go`/`mining.go`/`bankdecisions.go`/`decisions.go`, пин `TestAnEngineWrittenDocumentDropsTheRowInsteadOfKillingTheRun`, запись 42 | +| F9 | история в шапке забора льстила прошлому | `wirefence.go`, запись 43 | +| V2-3 | у сайдкара ТРИ состояния, код знал два | `manifest.go`, пин `TestASidecarFromAnOlderBuildStillYieldsAPrice`, запись 44 | +| V2-5 | книга платила за проход, которого не бывает, **и тест пинил неверное число** | `priceprojection.go` + пере-пин, запись 45 | +| V2-7 | один случайный form feed объявлял сорок девять догадок словом файла | `chunk/ingest.go`, запись 46 | +| V2-8 | тождество выводилось из равенства чисел | `priceprojection.go`, запись 47 | +| — | ссылка на идентификатор, которого в Go больше нет (три носителя) | `paidtail.go` + два дока зоны, запись 48 | +| — | **ДОФИКС F8 РОНЯЛ РЯД МОЛЧА** — найдено не приёмкой, а собственным чтением дофикса | `memvoice.go`/`memseed.go`/`mining.go`, запись 51 | +| F7 | два комментария ОДНОГО коммита противоречили о фолбэке цены; у находки не было владельца до 05.09 | `status.go`, запись 56 | + +**Каталог: 191 → 201.** Десять новых посадок держат дофикс, и это ровно десять ИМЁН: +`DF-engine-written-document-must-not-kill-the-run` и `DF-operator-seed-keeps-its-refusal` (расщепление по +авторству держится с ОБЕИХ сторон: если бы посадка была одна, «уроню всё» и «откажу всему» прошли бы +поодиночке) · `DF-old-sidecar-must-not-silence-the-price` · `DF-classifier-budget-only-when-it-runs` · +`DF-input-identity-is-stated-not-counted` · `DF-declared-means-every-boundary` · +`DF-money-follows-the-ceiling-not-the-outcome` · `DF-book-level-figure-is-a-forecast` и +`DF-no-book-level-spend-says-nothing` (F3 — тоже с обеих сторон: сказать неправду и сказать лишнее) · +`DF-a-dropped-row-is-not-silent` (запись 51). Плюс четыре ПЕРЕ-НАЦЕЛЕННЫЕ (запись 49). + +⚠ **Первая редакция этого абзаца объявляла F3 непокрываемым** — «носитель правки текст, покрасить +нечем, проверено чтением вывода». Это была ОТГОВОРКА, и она держалась ровно до попытки: `renderManifest` +принимает `io.Writer` и `*BookManifest`, тест пишется в двадцать строк без стенда и без денег, а +`cmd/tmctl/render.go` до сих пор не имел на эту функцию НИ ОДНОГО теста. Записано как есть, потому что +«нечем покрыть» — самая дешёвая из неправд, какие пишет о себе смена, и от честной она отличается +одной попыткой. + +⚠ **Что дофикс НЕ трогал:** ни одного теста не удалено и не переименовано (§6.2), ни одной строки в +чужой зоне, ни одного платного вызова. Батарея и каталог прогнаны на дереве ПОСЛЕ дофикса, не до. diff --git a/backend/docs/MONEY_HONESTY_REPORT.md b/backend/docs/MONEY_HONESTY_REPORT.md index 502ef77e..ae71b7e3 100644 --- a/backend/docs/MONEY_HONESTY_REPORT.md +++ b/backend/docs/MONEY_HONESTY_REPORT.md @@ -282,7 +282,7 @@ WARN'а, процитированного промтом. Следующий б три строки классификатора ($0.00918827) несут `degraded=cjk_artifact`, а их СОБСТВЕННЫЕ ответы лежат в `checkpoints.response_text` и здоровы — `finish=stop`, 163/101/150 знаков, содержимое вида `三转蛊师\tterm / 丙等\ttitle / … / 方源\tname`. Доля CJK в такой таблице высока ПО СПЕЦИФИКАЦИИ. -Механизм тоже прямой: `internal/pipeline/chunkrun.go:40`=`SourceEchoExpected: role == roleTerminologist` +Механизм ТОГДА: исключение выдавалось только терминологу; СЕГОДНЯ — обоим банк-ролям (`terminologist.go`, греп `isBankRole`) — исключение из эхо-правила выдано только терминологу, хотя формат ответа классификатора тот же. Плюс одна строка редактора `degraded=sanitizer_stripped` ($0.01865424) — её текст ОТГРУЖЕН. ⇒ наивная поверхность назвала бы оператору **на 29.2% больше потерь, чем было** diff --git a/backend/internal/chunk/ingest.go b/backend/internal/chunk/ingest.go index 2b49fd37..e2c455e6 100644 --- a/backend/internal/chunk/ingest.go +++ b/backend/internal/chunk/ingest.go @@ -168,7 +168,14 @@ func ingestTXT(p, encoding, sourceLang string) (*Document, error) { } // The form feed is the FORMAT speaking; the header match is this package guessing. Recorded here, // where the two paths are still distinguishable — one chapter list later they are not. - return &Document{Chapters: chapters, Structure: structureOf(chapters, len(parts) > 1)}, nil + // + // ⚠ «THE FORMAT DREW THE BOUNDARIES» MEANS ALL OF THEM, NOT ONE OF THEM. Asking only whether a form + // feed appeared called the whole cut `declared` on a book with fifty header-matched chapters and one + // stray \f — forty-nine guesses reported as the file's own word (acceptance V2-7). The honest test is + // whether the marker path added anything: if every chapter came from a form feed, the file said where + // its chapters are; if the header matcher found more, the cut is this package's inference whatever + // else is in the bytes. + return &Document{Chapters: chapters, Structure: structureOf(chapters, len(parts) > 1 && len(chapters) == len(parts))}, nil } // --- CJK chapter-header splitting (D18: real zh/ja txt mark chapters as «第N章/节/回») --------- diff --git a/backend/internal/chunk/ingest_test.go b/backend/internal/chunk/ingest_test.go index 6f5cb9ab..988e068d 100644 --- a/backend/internal/chunk/ingest_test.go +++ b/backend/internal/chunk/ingest_test.go @@ -46,6 +46,49 @@ func TestIngestTXTFormFeedChapters(t *testing.T) { } } +// TestTXTStructureIsDeclaredOnlyWhenTheFormatDrewEVERYBoundary pins the provenance of the cut, which is +// what a consumer offering an order «through chapter N» trusts when it decides whether N names something +// the file said or something this engine guessed. +// +// ⛔ «THE FORMAT DREW THE BOUNDARIES» MEANS ALL OF THEM. Asking only whether a form feed APPEARED called +// a fifty-chapter header-matched cut `declared` on the strength of one stray \f — forty-nine guesses +// reported as the file's own word (acceptance V2-7). The three cases below are the whole rule, and the +// MIXED one is the case that was wrong: it is the one no test held, which is how the defect shipped and +// how its repair then survived a mutation of itself. +func TestTXTStructureIsDeclaredOnlyWhenTheFormatDrewEVERYBoundary(t *testing.T) { + // A header line the matcher recognises, followed by enough prose to be a chapter. + chap := func(n string) string { return n + "\n" + strings.Repeat("蛊", 200) } + + for _, tc := range []struct { + name string + body string + want string + }{ + // Every boundary is a form feed: the file itself said where its chapters are. + {"the format drew every boundary", "ГЛАВА A" + chapterSep + "ГЛАВА B", StructureDeclared}, + // No form feed at all: every boundary is this package's inference from prose. + {"the matcher found them all", chap("第一章") + "\n" + chap("第二章"), StructureDetected}, + // ⛔ THE CASE THAT WAS WRONG: one form feed, and the matcher then found MORE chapters inside the + // parts. The file drew one boundary out of three, so the cut as a whole is an inference — and + // calling it `declared` would report two guesses as the file's own word. + {"one stray form feed among matched headers", chap("第一章") + "\n" + chap("第二章") + chapterSep + chap("第三章"), StructureDetected}, + } { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "src.txt") + if err := os.WriteFile(path, []byte(tc.body), 0o644); err != nil { + t.Fatal(err) + } + doc, err := ingest(path) + if err != nil { + t.Fatal(err) + } + if doc.Structure != tc.want { + t.Fatalf("structure = %q, want %q (chapters: %d)", doc.Structure, tc.want, len(doc.Chapters)) + } + }) + } +} + func TestIngestTXTNormalizes(t *testing.T) { path := filepath.Join(t.TempDir(), "src.txt") // UTF-8 BOM + CRLF + surrounding whitespace must be normalized away. diff --git a/backend/internal/membank/decisions.go b/backend/internal/membank/decisions.go index e2a7d3ad..10b3c3d9 100644 --- a/backend/internal/membank/decisions.go +++ b/backend/internal/membank/decisions.go @@ -879,7 +879,7 @@ func inspectDocuments(in ApplyInput, delta seed.File, rejects seed.RejectFile) d return v } v.delta = raw - bs, err := ParseBankSeed("mined-delta", raw) + bs, err := ParseEngineBankSeed("mined-delta", raw) if err != nil { // One entry PER SUBJECT, not one per verdict. The loader accumulates its faults and joins them // for a human; joined, they are a single string that changes whenever any one of them is fixed. diff --git a/backend/internal/membank/memseed.go b/backend/internal/membank/memseed.go index bfae2499..32c4419f 100644 --- a/backend/internal/membank/memseed.go +++ b/backend/internal/membank/memseed.go @@ -37,11 +37,45 @@ func LoadBankSeed(path string) (BankSeed, error) { return ParseBankSeed(path, raw) } +// LoadEngineBankSeed is LoadBankSeed for a document THIS ENGINE WROTE — see ParseEngineBankSeed for the +// one rule that differs and why. +func LoadEngineBankSeed(path string) (BankSeed, error) { + raw, err := os.ReadFile(path) + if err != nil { + return BankSeed{}, fmt.Errorf("membank: read engine-written bank document %s: %w", path, err) + } + return ParseEngineBankSeed(path, raw) +} + // ParseBankSeed is LoadBankSeed over bytes already in hand. It exists because `bank-apply` has to know // whether the document it is ABOUT to write would load — asking that question by writing the file first // and reading it back is the one answer a door that refuses on all-or-nothing cannot use. `name` is what // error messages call the document; for a file it is its path. func ParseBankSeed(name string, raw []byte) (BankSeed, error) { + return parseBankSeed(name, raw, false) +} + +// ParseEngineBankSeed is ParseBankSeed for a document THIS ENGINE WROTE — the mined delta and the +// auto-bank, both of which are built from MODEL OUTPUT rather than typed by a person. +// +// ⛔ THE DIFFERENCE IS ONE RULE AND IT IS ABOUT WHOSE ARTIFACT IT IS. The wire fence refuses a value that +// could write into a system message, and for the operator's own seed refusing the DOCUMENT is right: it +// is theirs to fix, and dropping the row would leave a term they believe is in force silently absent +// from every request. For a document the engine wrote from a model's answer the same refusal is wrong in +// both directions — it aborts a PAID run over the engine's own output (mining.go's loadMinedDelta and +// the auto-bank both hand their error straight up, and bankmaterialize turns it into a dead run), and it +// asks a person to hand-edit a file no person authored. So an unfit row is DROPPED here: it never enters +// the bank, therefore never the wire, and the run continues. +// +// Found by acceptance (F8), which named the mined delta; the auto-bank is the same class and is included +// because it is written from model output on EVERY run, which makes it the likelier of the two. +func ParseEngineBankSeed(name string, raw []byte) (BankSeed, error) { + return parseBankSeed(name, raw, true) +} + +// parseBankSeed is the shared core. `engineWritten` decides only what an unfit row costs — see +// ParseEngineBankSeed. +func parseBankSeed(name string, raw []byte, engineWritten bool) (BankSeed, error) { var bs BankSeed path := name // STRICT (seed.DecodeFile): yaml.v3 drops an unknown key silently, so `gendr:` for `gender:` used to @@ -114,9 +148,18 @@ func ParseBankSeed(name string, raw []byte) (BankSeed, error) { // dropping the row on the way to the wire, would leave a term the operator believes is in force // silently absent from every request. `%q` is load-bearing in the message: it Go-quotes the very // runes being refused, so the fault can be printed without reproducing it. - for _, why := range WireUnfitReasons(t.Src, t.Dst) { - problems.addKeyed(subjectOf("term-wire", t.Src, t.Sense, t.SinceCh, t.UntilCh), - fmt.Sprintf("term %q: %s", t.Src, why)) + if reasons := WireUnfitReasons(t.Src, t.Dst); len(reasons) > 0 { + if engineWritten { + // A document the ENGINE wrote from a model's answer: drop the row and carry on. Refusing + // would abort a paid run over our own output — see ParseEngineBankSeed. ⚠ The drop is + // RECORDED, not silent: BankSeed.Dropped carries it out to a caller that has a log. + bs.Dropped = append(bs.Dropped, t.Src) + continue + } + for _, why := range reasons { + problems.addKeyed(subjectOf("term-wire", t.Src, t.Sense, t.SinceCh, t.UntilCh), + fmt.Sprintf("term %q: %s", t.Src, why)) + } } decl := "" if t.Decl != nil { diff --git a/backend/internal/membank/memvoice.go b/backend/internal/membank/memvoice.go index e86e2d7d..d2ca6c38 100644 --- a/backend/internal/membank/memvoice.go +++ b/backend/internal/membank/memvoice.go @@ -38,6 +38,15 @@ type BankSeed struct { Terms []store.GlossaryEntry Voices []store.VoiceProfile Pairs []store.AddressPair + // Dropped names the `src` of every row this loader REMOVED instead of refusing the document — an + // engine-written document only (ParseEngineBankSeed); for an operator's seed it is always empty, + // because their document is refused whole and nothing is silently mended. + // + // ⛔ IT EXISTS SO THAT A DROP IS NOT SILENT. Trading a loud death for a quiet disappearance would be + // the worse half of the fix: a term the model mined vanishes from every request, and an operator + // asking why has nothing to read. The loader has no logger and should not grow one, so it REPORTS + // and the caller with the log speaks (mining.go). + Dropped []string } // LoadGlossarySeed parses the TERMS of a seed file — the pre-pack-19 entry point, kept for every caller @@ -50,6 +59,20 @@ func LoadGlossarySeed(path string) ([]store.GlossaryEntry, error) { return s.Terms, nil } +// LoadEngineGlossarySeed is LoadGlossarySeed for a document THIS ENGINE WROTE — the mined delta and the +// auto-bank. The difference is one rule, and it is about whose artifact it is: see +// membank.ParseEngineBankSeed. +// +// ⚠ IT RETURNS WHAT IT DROPPED, and a caller that throws that away makes the drop silent — which is the +// half of this fix that would be worth less than the defect it replaced (BankSeed.Dropped). +func LoadEngineGlossarySeed(path string) (terms []store.GlossaryEntry, dropped []string, err error) { + s, err := LoadEngineBankSeed(path) + if err != nil { + return nil, nil, err + } + return s.Terms, s.Dropped, nil +} + // loadVoiceSections validates and materializes the voices:/addresses: sections of a parsed seed file. // Fail-loud on anything that would be silently inert or would crash the UNIQUE constraint mid-run, which // is the same contract the terms half keeps. diff --git a/backend/internal/membank/wirefence.go b/backend/internal/membank/wirefence.go index 40f30a60..dbc7d8d7 100644 --- a/backend/internal/membank/wirefence.go +++ b/backend/internal/membank/wirefence.go @@ -10,12 +10,20 @@ import ( // wirefence.go: the boundary between BANK DATA and the SYSTEM MESSAGE. // // The ratified invariant (D25, fact base research/17 §A10.2) is that the book's text and any external -// output are DATA, never instructions. Until the bank gained an external writer that invariant held BY -// CONSTRUCTION — nobody outside this process could put bytes into a prompt. That construction is gone: -// `POST /books/{id}/bank/corrections` accepts a `dst`, the contract declares `minLength: 1` and no -// maximum, and the renderers below concatenate it into a message whose role is `system` on EVERY paid -// call of the book. A `dst` holding a line feed therefore writes its own lines inside the system block, -// and there was nothing between the door and the wire to stop it (backlog row 271). +// output are DATA, never instructions. `POST /books/{id}/bank/corrections` accepts a `dst`, the contract +// declares `minLength: 1` and no maximum, and the renderers below concatenate it into a message whose +// role is `system` on EVERY paid call of the book. A `dst` holding a line feed therefore writes its own +// lines inside the system block, and there was nothing between the door and the wire to stop it +// (backlog row 271). +// +// ⚠ AND THE FIRST VERSION OF THIS PARAGRAPH FLATTERED THE PAST: it said the invariant «held BY +// CONSTRUCTION until the bank gained an external writer — nobody outside this process could put bytes +// into a prompt». That was never quite true, and acceptance (F9) named it. A MINED term is not typed by +// anyone outside the process, but it is not written by us either — it is the MODEL'S OWN OUTPUT, folded +// into the mined delta and the auto-bank and rendered into the same system block. So the untrusted +// writer arrived with mining, not with the correction door; the door only made it a PERSON. The fence +// covers both by construction, which is why the correction is to the history and not to the code — but a +// history that says «this was impossible before» invites the next reader to trust the wrong boundary. // // So the invariant becomes a MECHANISM. One predicate, three enforcement points, and the same answer at // all three: diff --git a/backend/internal/membank/wirefence_test.go b/backend/internal/membank/wirefence_test.go index 23825f43..fd4939da 100644 --- a/backend/internal/membank/wirefence_test.go +++ b/backend/internal/membank/wirefence_test.go @@ -189,3 +189,61 @@ func TestTheFenceMovesOnlyTheBankThatCarriesAnUnfitRow(t *testing.T) { t.Error("base (draft-wave) scope must see the fence too") } } + +// TestAnEngineWrittenDocumentDropsTheRowInsteadOfKillingTheRun is the authorship split of the fence, +// found by acceptance (F8) and widened here. +// +// ⛔ THE SAME LOADER READS THREE DOCUMENTS AND ONLY ONE OF THEM HAS AN AUTHOR. The operator's +// `glossary_seed` is typed by a person; the mined delta and the AUTO-BANK are written by this engine from +// MODEL OUTPUT, on every run. Refusing the document is right for the first — it is theirs to fix, and +// dropping the row would leave a term they believe is in force silently absent from every request. For +// the other two the same refusal is wrong in both directions: it aborts a PAID run over our own output +// (loadMinedDelta and the auto-bank hand their error straight up, and bank materialization turns it into +// a dead run), and it asks a person to hand-edit a file no person authored. +// +// ⚠ Acceptance named the mined delta. The auto-bank is the same class and is the LIKELIER of the two, +// because it is written from model output on every single run — which is why this test asserts the rule, +// not the caller. +func TestAnEngineWrittenDocumentDropsTheRowInsteadOfKillingTheRun(t *testing.T) { + // One good term and one whose rendering carries a control character — the shape a model can produce. + doc := []byte("terms:\n" + + " - src: \"方源\"\n dst: \"Фан Юань\"\n status: approved\n" + + " - src: \"蛊\"\n dst: \"Гу\\tГу\"\n status: approved\n") + + // THE OPERATOR'S DOCUMENT: refused, and the refusal names the fault so a person can fix it. + if _, err := ParseBankSeed("glossary_seed.yaml", doc); err == nil { + t.Fatal("an operator's seed carrying a value that can write into a system message must be refused, not silently mended") + } else if !strings.Contains(err.Error(), "control character") { + t.Errorf("the refusal must name the class of the fault, got %q", err.Error()) + } + + // THE ENGINE'S OWN DOCUMENT: loads, minus the row. + bs, err := ParseEngineBankSeed("mined-delta", doc) + if err != nil { + t.Fatalf("an engine-written document must not kill the run over the engine's own output: %v", err) + } + if len(bs.Terms) != 1 { + t.Fatalf("want exactly the one fit term, got %d — %+v", len(bs.Terms), bs.Terms) + } + if bs.Terms[0].Src != "方源" { + t.Errorf("the wrong term survived: %+v", bs.Terms[0]) + } + // ⛔ AND THE GUARANTEE IS NOT WEAKENED BY THE DROP: the row never enters the bank, so it can never + // reach the wire. Asserted rather than argued — the renderer is asked directly. + for _, e := range bs.Terms { + if WireUnfitRow(e.Src, e.Dst) { + t.Fatalf("an unfit row entered the bank of an engine-written document: %+v", e) + } + } + + // ⛔ AND THE DROP IS NOT SILENT. Trading a loud death for a quiet disappearance would be the worse + // half of this fix: the term vanishes from every request, and an operator asking why has nothing to + // read. The loader has no logger, so it REPORTS and the caller with the log speaks (mining.go). + if len(bs.Dropped) != 1 || bs.Dropped[0] != "蛊" { + t.Fatalf("the removed row must be named to the caller, got %q", bs.Dropped) + } + // The operator's path has nothing to report — their document is refused whole, never mended. + if op, _ := ParseBankSeed("glossary_seed.yaml", doc); len(op.Dropped) != 0 { + t.Errorf("an operator's document must not silently mend anything, got dropped %q", op.Dropped) + } +} diff --git a/backend/internal/pipeline/bankdecisions.go b/backend/internal/pipeline/bankdecisions.go index cbfc3bcd..97ed060b 100644 --- a/backend/internal/pipeline/bankdecisions.go +++ b/backend/internal/pipeline/bankdecisions.go @@ -593,7 +593,7 @@ func signatureState(book *config.Book, seedRows []store.GlossaryEntry, docs deci } var minedRows []store.GlossaryEntry if len(docs.delta) > 0 { - bs, perr := membank.ParseBankSeed("mined-delta", docs.delta) + bs, perr := membank.ParseEngineBankSeed("mined-delta", docs.delta) if perr != nil { return SignatureState{Map: absPath(path), Unreadable: true} } diff --git a/backend/internal/pipeline/manifest.go b/backend/internal/pipeline/manifest.go index e531679a..37d60005 100644 --- a/backend/internal/pipeline/manifest.go +++ b/backend/internal/pipeline/manifest.go @@ -641,20 +641,35 @@ func (r *Runner) readModelChunks() (chunks []chunk.Chunk, withText func() ([]chu } // readModelPrice is the a-priori price and the cut's provenance for a $0 read path — from the persisted -// sidecar when it is current, and from the cut this read has already made when it is not. +// sidecar when it can answer, and from the cut this read has already made when it cannot. +// +// ⚠ «CAN ANSWER» IS THREE STATES, NOT TWO, and the two-state wording this comment used to carry is what +// let the third slip past: a sidecar is absent, or stale, or CURRENT AND OLDER THAN THESE FIELDS. The +// third reads as healthy to every check the loader makes — see the body. // // ⛔ THE FALLBACK IS NOT AN EXTRA, IT IS THE INVARIANT. `status` must project the same report whichever // path served it, and the manifest is an ACCELERATOR: an accelerator that changes the answer is a second -// source of truth. Without this branch a book whose sidecar was stale or absent reported no price at all -// — silently, and precisely on the surface a buyer's platform reads before deciding — while the same -// book with a sidecar reported one. Both branches end in projectBook, so there is one derivation and +// source of truth. Without this branch a book whose sidecar cannot answer reports no price at all — +// silently, and precisely on the surface a buyer's platform reads before deciding — while the same book +// with an answering sidecar reports one. Both branches end in projectBook, so there is one derivation and // not two that agree. // // The cut's provenance comes with the cut: `structure` is a statement about how the boundaries were // drawn, which only the ingest knows, so the fallback takes it from the ingest that just ran rather than // trying to recover it from chunks that no longer remember. func (r *Runner) readModelPrice(withText func() ([]chunk.Chunk, error)) (*BookPrice, string) { - if m := r.loadManifest(); m != nil { + // ⛔ A CURRENT SIDECAR CAN STILL PREDATE THESE FIELDS, and that is a THIRD state, not a shade of the + // other two. `price` and `structure` are ADDITIVE, so the document version deliberately did not move + // for them (moving it would discard every stored sidecar and re-cut every book — see manifestVersion). + // The consequence is that a file written by an older build passes the version, passes selfConsistent + // and passes the validity key, comes back as «current», and carries no price at all — so the branch + // above returned nil and the fallback below, which this comment calls the invariant, never ran. The + // book then reported no price SILENTLY, on the surface a buyer's platform reads before deciding, + // which is precisely the failure the fallback exists to prevent — found by acceptance (V2-3). + // + // So the liveness of an additive field is asked ABOUT THE FIELD, not about the document: a sidecar + // that cannot answer this question is, for this question, no sidecar. + if m := r.loadManifest(); m != nil && m.Price != nil && m.Structure != "" { return m.Price, m.Structure } full, err := withText() diff --git a/backend/internal/pipeline/mining.go b/backend/internal/pipeline/mining.go index ef1f9958..7583b886 100644 --- a/backend/internal/pipeline/mining.go +++ b/backend/internal/pipeline/mining.go @@ -637,7 +637,8 @@ func (r *Runner) loadAutoBank(signed []store.GlossaryEntry) (rows []store.Glossa } return nil, nil, fmt.Errorf("pipeline: stat auto-bank %s: %w", r.autoBankPath(), serr) } - entries, err := membank.LoadGlossarySeed(r.autoBankPath()) + entries, dropped, err := membank.LoadEngineGlossarySeed(r.autoBankPath()) + r.warnDroppedRows("auto-bank", r.autoBankPath(), dropped) if err != nil { return nil, nil, fmt.Errorf("pipeline: load auto-bank %s: %w", r.autoBankPath(), err) } @@ -776,7 +777,9 @@ func ownerHandled(seed []store.GlossaryEntry, rejects map[string]bool) map[strin // in file order. Absent or unreadable → nil: the diff is observability, and failing a paid run because the // PREVIOUS artifact cannot be parsed would be the tail wagging the dog. func (r *Runner) autoBankSurfaces() []string { - entries, err := membank.LoadGlossarySeed(r.autoBankPath()) + // The drop is reported by loadAutoBank, which reads the same file on the paid path; saying it twice + // per run would make the warning noise instead of news. + entries, _, err := membank.LoadEngineGlossarySeed(r.autoBankPath()) if err != nil { return nil } @@ -843,6 +846,25 @@ func decisionFilePresent(path string) (bool, error) { } } +// warnDroppedRows says out loud that the wire fence removed a row from a document THIS ENGINE WROTE. +// +// ⛔ A DROP THAT NOBODY CAN READ IS THE WORSE HALF OF THE FIX. Refusing an engine-written document killed +// a paid run over our own output, so the row is dropped instead (membank.ParseEngineBankSeed) — but a +// mined term that vanishes from every request with no trace leaves an operator asking why a term they +// can see in the file is not applied, and nothing to answer with. So the loader REPORTS what it removed +// and this is where the engine speaks, beside the DECLINED-term warning it is modelled on. +// +// Warn and not Error: the run is correct and complete without the row, and the fault is in a model's +// answer rather than in anything the operator did. +func (r *Runner) warnDroppedRows(kind, path string, dropped []string) { + if len(dropped) == 0 { + return + } + r.Log.Warn("wire fence removed term(s) from an engine-written bank document; they are NOT entering the bank and NOT reaching any request (the model's answer carried a rune that can write into a system message)", + "book", r.Book.BookID, "document", kind, "path", path, + "dropped", strings.Join(dropped, ", "), "count", len(dropped)) +} + // loadMinedDelta reads the owner-curated mined-delta YAML (book.MinedDelta) and stamps every entry // Source:"mined" — NOT via membank.LoadGlossarySeed (which hardcodes Source:"seed", memseed.go, moving the base // bank / draft-wave snapshot). This is the mined-write path (plan §1(в), F2): the mined terms land in the ENRICHED @@ -854,7 +876,8 @@ func (r *Runner) loadMinedDelta() ([]store.GlossaryEntry, error) { if err != nil || !present { return nil, err } - entries, err := membank.LoadGlossarySeed(r.Book.MinedDelta) + entries, dropped, err := membank.LoadEngineGlossarySeed(r.Book.MinedDelta) + r.warnDroppedRows("mined-delta", r.Book.MinedDelta, dropped) if err != nil { return nil, fmt.Errorf("pipeline: load mined-delta %s: %w", r.Book.MinedDelta, err) } diff --git a/backend/internal/pipeline/moneystop_test.go b/backend/internal/pipeline/moneystop_test.go index e0b1c9a7..2eb29584 100644 --- a/backend/internal/pipeline/moneystop_test.go +++ b/backend/internal/pipeline/moneystop_test.go @@ -15,6 +15,7 @@ import ( "testing" "time" + "textmachine/backend/internal/chunk" "textmachine/backend/internal/ledger" "textmachine/backend/internal/obs" "textmachine/backend/internal/runevents" @@ -952,3 +953,90 @@ func TestTheTerminalVocabularyNeverContradictsTheExitCode(t *testing.T) { }) } } + +// TestTheMoneyLedgerFollowsTheCEILINGAndNotTheOUTCOME pins BOTH directions of `Finished.money`'s presence +// rule, neither of which had a pin before acceptance found the contract file saying something else. +// +// ⛔ THE CONTRACT FILE SAID «present only on `outcome: ceiling`» AND BOTH VERIFIERS CAUGHT IT +// INDEPENDENTLY. It is the doc a CONSUMER IN ANOTHER ZONE reads, so the two readings it invited are two +// different bugs in somebody else's code: «money ⇒ the run stopped on a ceiling» is wrong on `stopped` +// and `failed`, and «ceiling ⇒ money is there» is wrong when the run never learned the book's cut. The +// real rule is «a ceiling was REACHED», and it is asserted here in both directions because a sentence +// nothing pins is a sentence that drifts. +func TestTheMoneyLedgerFollowsTheCeilingAndNotTheOutcome(t *testing.T) { + halt := &CeilingHalt{Scope: runevents.ScopeBook, ShortfallMicroUSD: 7, + err: fmt.Errorf("pipeline: book USD ceiling reached: %w", errReserveCeiling)} + + // A cut the counters can be seeded from — one output unit, one draft stage, none of it resolved. + units := []editUnit{{Chapter: 1, FirstChunkIdx: 0, Members: []chunk.Chunk{{Chapter: 1, ChunkIdx: 0}}}} + shape := waveShape{draftNames: map[string]bool{"draft": true}, nDraft: 1} + + for _, tc := range []struct { + name string + err error + ceiling bool + seedCut bool + wantOutcome string + wantMoney bool + }{ + // DIRECTION 1 — money rides the ceiling, not the word. Each of these departs as something else. + {"a caught SIGTERM after the money ran out", context.Canceled, true, true, runevents.OutcomeStopped, true}, + {"an infra failure on top of a caught ceiling", errors.New("provider unreachable"), true, true, runevents.OutcomeFailed, true}, + {"a signing stop on a book that touched its ceiling", &WaveSignatureStop{Terms: 1, SignaturePath: "/x"}, true, true, runevents.OutcomeBankStop, true}, + {"the tidy ceiling stop", halt, true, true, runevents.OutcomeCeiling, true}, + // DIRECTION 2 — the word does not imply the field. + {"a ceiling stop before the run learned the book's cut", halt, true, false, runevents.OutcomeCeiling, false}, + // …and no ceiling at all means no ledger, whatever else happened. + {"a plain infra failure, no ceiling anywhere", errors.New("provider unreachable"), false, true, runevents.OutcomeFailed, false}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(filepath.Join(dir, "p.db")) + if err != nil { + t.Fatal(err) + } + defer st.Close() + e, err := openEmitter(st, dir, "trace-money-rule", "test-book", slog.New(slog.NewTextHandler(io.Discard, nil))) + if err != nil { + t.Fatal(err) + } + if tc.seedCut { + e.beginWaves(units, shape, map[chunkKey][]store.ChunkStatus{}) + } + if tc.ceiling { + e.ceilingReached(halt) + } + e.terminal(nil, tc.err) + e.flush() + + raw, rerr := os.ReadFile(filepath.Join(dir, "events.jsonl")) + if rerr != nil { + t.Fatal(rerr) + } + var outcome string + var hasMoney bool + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + var env struct { + Type string `json:"type"` + Data json.RawMessage `json:"data"` + } + if line == "" || json.Unmarshal([]byte(line), &env) != nil || env.Type != "finished" { + continue + } + var f struct { + Outcome string `json:"outcome"` + Money json.RawMessage `json:"money"` + } + mustJSON(t, env.Data, &f) + outcome, hasMoney = f.Outcome, len(f.Money) > 0 + } + if outcome != tc.wantOutcome { + t.Fatalf("outcome = %q, want %q", outcome, tc.wantOutcome) + } + if hasMoney != tc.wantMoney { + t.Fatalf("money present = %v, want %v — the field follows «a ceiling was REACHED», not the outcome word; a consumer building on either reading gets a different bug", + hasMoney, tc.wantMoney) + } + }) + } +} diff --git a/backend/internal/pipeline/paidtail.go b/backend/internal/pipeline/paidtail.go index d94cce79..f08f17fd 100644 --- a/backend/internal/pipeline/paidtail.go +++ b/backend/internal/pipeline/paidtail.go @@ -17,9 +17,9 @@ import ( // `request_log WHERE ok = 0 AND cost_usd > 0`. On that run it selects 11 rows and $0.12316089 — and THREE // different things: // - the classifier's three calls ($0.00918827), which SUCCEEDED. Their replies are healthy term tables -// (`三转蛊师term …`, finish=stop) and they are flagged only because the CJK-echo rule is exempted -// for the terminologist and not for the classifier (chunkrun.go, `SourceEchoExpected`), so a -// bilingual table reads as an echo of its own source; +// (`三转蛊师term …`, finish=stop), flagged because the CJK-echo exemption covered ONLY the +// terminologist AT THE TIME — it covers both bank roles now (terminologist.go, `isBankRole`) — so a +// bilingual table read as an echo of its own source; // - one editor call ($0.01865424) whose text SHIPPED after a cosmetic sanitizer strip; // - and the rest, which genuinely bought nothing. // A surface built on that slice would have told the operator he lost 29.2% more than he did — a money diff --git a/backend/internal/pipeline/priceprojection.go b/backend/internal/pipeline/priceprojection.go index 7a0aba87..b02e5be6 100644 --- a/backend/internal/pipeline/priceprojection.go +++ b/backend/internal/pipeline/priceprojection.go @@ -174,8 +174,8 @@ func (p *pricePlan) projectUnit(u editUnit) unitPrice { // The unit's draft, as the edit wave will see it: the members' outputs joined. draftTokens := 0 for _, m := range u.Members { - draftTokens += p.walkDraftCalls(m, func(sp stagePrice, srcTok, in, out int) { - usage := llm.Usage{PromptTokens: p.promptTokens(sp, srcTok, in), CompletionTokens: out} + draftTokens += p.walkDraftCalls(m, func(sp stagePrice, srcTok, in, out int, inIsSource bool) { + usage := llm.Usage{PromptTokens: p.promptTokens(sp, srcTok, in, inIsSource), CompletionTokens: out} up.ExpectedUSD += ledger.CostUSD(sp.price, usage) }) } @@ -183,6 +183,7 @@ func (p *pricePlan) projectUnit(u editUnit) unitPrice { // The later stages, chained the same way — each reads what the previous produced and returns // something of that size. in, out := draftTokens, draftTokens + laterReadsSource := false if draftTokens == 0 { // ⚠ A PIPELINE WITH NO TRANSLATOR STAGE AT ALL, and it needs TWO different fallbacks, not one. // runStageSequence starts such a wave with an empty `prev`, and the first stage's global index is @@ -192,14 +193,16 @@ func (p *pricePlan) projectUnit(u editUnit) unitPrice { // for both under-states that stage's completion by the whole fertility coefficient — about a // sixth on the shipped pair. in, out = up.PromptTokens, p.expectedDraftTokens(dense, sparse) + laterReadsSource = true // its FIRST stage reads ch.Text itself; the ones after it read a draft } for _, sp := range p.stages { if sp.isDraft { continue } - usage := llm.Usage{PromptTokens: p.promptTokens(sp, up.PromptTokens, in), CompletionTokens: out} + usage := llm.Usage{PromptTokens: p.promptTokens(sp, up.PromptTokens, in, laterReadsSource), CompletionTokens: out} up.ExpectedUSD += ledger.CostUSD(sp.price, usage) in = out + laterReadsSource = false } draftTokens = in up.DraftTokens = draftTokens @@ -217,11 +220,11 @@ func (p *pricePlan) projectUnit(u editUnit) unitPrice { // stage ROLES and imposes no count — volume.go says so in its own words). Priced as a flat sum over // stages instead, a second one would be charged against the SOURCE while the executor feeds it the first // one's output. -func (p *pricePlan) walkDraftCalls(m chunk.Chunk, fn func(sp stagePrice, sourceTok, in, out int)) int { +func (p *pricePlan) walkDraftCalls(m chunk.Chunk, fn func(sp stagePrice, sourceTok, in, out int, inIsSource bool)) int { srcTok := EstimateTokens(m.Text) dense, sparse := text.DenseSparseCounts(m.Text) in, out := srcTok, p.expectedDraftTokens(dense, sparse) - first := true + first, isSource := true, true for _, sp := range p.stages { if !sp.isDraft { continue @@ -231,7 +234,8 @@ func (p *pricePlan) walkDraftCalls(m chunk.Chunk, fn func(sp stagePrice, sourceT // returns something of that size rather than translating again. in = out } - fn(sp, srcTok, in, out) + fn(sp, srcTok, in, out, isSource) + isSource = false first = false } if first { @@ -260,10 +264,16 @@ func (p *pricePlan) walkDraftCalls(m chunk.Chunk, fn func(sp stagePrice, sourceT // // It is read off the TEMPLATE and not off the stage's index, because that is where the fact lives: a // monolingual editor arm carries no `{{text}}` and is priced without a source, on the same lines. -func (p *pricePlan) promptTokens(sp stagePrice, sourceTokens, inputTokens int) int { +// +// ⚠ WHETHER THE INPUT *IS* THE SOURCE IS STATED BY THE CALLER, NOT INFERRED FROM EQUAL NUMBERS. The first +// version asked `inputTokens != sourceTokens` — and two token counts can coincide without being the same +// text: a pair whose fertility sits near 1.0 makes a draft the size of its source, and then the source +// silently stopped being charged to a stage that receives it (acceptance V2-8). Identity is a fact the +// caller has and the arithmetic does not. +func (p *pricePlan) promptTokens(sp stagePrice, sourceTokens, inputTokens int, inputIsSource bool) int { n := sp.tplTokens + inputTokens + p.inject - if sp.carriesSource && inputTokens != sourceTokens { - // The first stage's input IS the source; adding it twice would double-charge it. + if sp.carriesSource && !inputIsSource { + // A stage whose input is already the source must not be charged for it twice. n += sourceTokens } return n @@ -350,8 +360,8 @@ func (r *Runner) stepMaxForUnit(p *pricePlan, u editUnit, up unitPrice) float64 // a platform sets its minimum purchase by. The SAME walk the expected bill uses, so the two cannot // disagree about which calls exist or how they chain. for _, m := range u.Members { - p.walkDraftCalls(m, func(sp stagePrice, srcTok, in, out int) { - consider(sp, in, p.promptTokens(sp, srcTok, in)) + p.walkDraftCalls(m, func(sp stagePrice, srcTok, in, out int, inIsSource bool) { + consider(sp, in, p.promptTokens(sp, srcTok, in, inIsSource)) }) } for _, sp := range p.stages { @@ -359,7 +369,7 @@ func (r *Runner) stepMaxForUnit(p *pricePlan, u editUnit, up unitPrice) float64 continue } // A later stage sizes from the previous stage's OUTPUT (D2.5) — the unit's whole draft. - consider(sp, up.DraftTokens, p.promptTokens(sp, up.PromptTokens, up.DraftTokens)) + consider(sp, up.DraftTokens, p.promptTokens(sp, up.PromptTokens, up.DraftTokens, false)) } return max } @@ -382,5 +392,24 @@ func (r *Runner) bookOnceUSD() float64 { if !g.Terminology.Enabled || r.Pipeline.Mining.ContrastPath == "" { return 0 } - return g.Terminology.BudgetUSD + g.Terminology.ClassifyBudgetUSD + usd := g.Terminology.BudgetUSD + // ⛔ THE CLASSIFIER'S BUDGET ONLY WHEN THE CLASSIFIER RUNS. `classify_types` gates the phase itself + // (terminologist.go: `if !g.ClassifyTypes … return`), and the loader requires `classify_budget_usd > 0` + // ONLY when that toggle is on — so with the phase off the key is free to hold whatever a config left + // there, and adding it charged a book for a pass that cannot happen. Found by acceptance (V2-5). + // + // ⚠ WHERE IT BITES, STATED EXACTLY: the config this repository ships has the toggle ON + // (`configs/pipeline-c1.yaml:170` = `classify_types: true`), so the shipped arm never showed the fault + // — the budget it adds is a budget that is really spent. The defect reaches a configuration whose + // toggle is OFF while the key still carries a figure left by an earlier edition, which the loader + // permits precisely because it stops validating that key once the phase is off. ⚠ An earlier version + // of this comment claimed the shipped arm was overcharged by half ($1.00 of $2.00); that was measured + // nowhere and is false — the number is real, the arm is not. + // + // ⚠ AND THE TEST OF THIS FUNCTION PINNED THE WRONG NUMBER — a gate defending the defect, which no + // review that reads green can catch. + if g.Terminology.ClassifyTypes { + usd += g.Terminology.ClassifyBudgetUSD + } + return usd } diff --git a/backend/internal/pipeline/priceprojection_test.go b/backend/internal/pipeline/priceprojection_test.go index 7a596f0e..4f91f960 100644 --- a/backend/internal/pipeline/priceprojection_test.go +++ b/backend/internal/pipeline/priceprojection_test.go @@ -442,17 +442,24 @@ func TestTheProjectionChargesTheSourceToEveryStageThatAsksForIt(t *testing.T) { p := &pricePlan{inject: 100} const source, draft = 500, 300 // A LATER stage reading a draft: the source rides too, and is charged. - if got, want := p.promptTokens(bilingual, source, draft), bilingual.tplTokens+draft+100+source; got != want { + if got, want := p.promptTokens(bilingual, source, draft, false), bilingual.tplTokens+draft+100+source; got != want { t.Errorf("a bilingual later stage prices %d, want %d — the source is missing from the prompt", got, want) } // The MONOLINGUAL arm on the same shape is priced without it. - if got, want := p.promptTokens(mono, source, draft), mono.tplTokens+draft+100; got != want { + if got, want := p.promptTokens(mono, source, draft, false), mono.tplTokens+draft+100; got != want { t.Errorf("a monolingual arm prices %d, want %d — it was charged for a source it never receives", got, want) } // ⚠ And the FIRST stage is not charged twice: its input IS the source. - if got, want := p.promptTokens(bilingual, source, source), bilingual.tplTokens+source+100; got != want { + if got, want := p.promptTokens(bilingual, source, source, true), bilingual.tplTokens+source+100; got != want { t.Errorf("the first stage prices %d, want %d — the source was counted twice", got, want) } + // ⛔ IDENTITY IS STATED, NOT COUNTED. A later stage whose draft happens to have exactly the source's + // token count — a pair whose fertility sits near 1.0 makes one — is still a LATER stage, and the + // source it receives must still be charged. Comparing the two numbers, as the first version did, made + // this case silently free (acceptance V2-8). + if got, want := p.promptTokens(bilingual, source, source, false), bilingual.tplTokens+source+100+source; got != want { + t.Errorf("a later stage whose draft coincides in size with the source prices %d, want %d — identity was inferred from equal numbers instead of being stated", got, want) + } } // TestTheDraftIsPricedPerMemberChunkAndTheEditPerUnit pins the GRANULARITY of the projection against the @@ -527,7 +534,11 @@ func TestTheBookLevelChargeAppearsOnlyWhenTheBookWillPayIt(t *testing.T) { templates: map[string]*PromptTemplate{"s": {System: "S", User: "U " + sourcePlaceholder}}, } } - on := config.TerminologyGate{Enabled: true, BudgetUSD: 0.25, ClassifyBudgetUSD: 0.75} + // ⚠ `ClassifyTypes` IS SET HERE AND WAS NOT BEFORE, and that omission is why this test used to pin the + // WRONG number: with the phase off the classifier cannot run, so its budget is not part of what the + // book will pay — and the assertion below demanded that it be added anyway. A test defending the + // defect it is meant to guard survives every review that reads green (acceptance V2-5). + on := config.TerminologyGate{Enabled: true, ClassifyTypes: true, BudgetUSD: 0.25, ClassifyBudgetUSD: 0.75} // BOTH halves of the condition, each on its own: the gate alone is not enough, and the artifact alone // is not either — the pass does not run without both, so the book does not pay for it. @@ -538,9 +549,17 @@ func TestTheBookLevelChargeAppearsOnlyWhenTheBookWillPayIt(t *testing.T) { t.Errorf("the artifact is named but the gate is off: got %v", usd) } + // AND THE PHASE-OFF CASE, which is the one that was wrong: the classifier cannot run, so its budget is + // not part of what the book will pay. + phaseOff := on + phaseOff.ClassifyTypes = false + if usd := priced(phaseOff, "/corpus").bookOnceUSD(); usd != 0.25 { + t.Fatalf("with `classify_types` OFF the book is charged %v — the classifier phase cannot run, so its budget is not a bound on anything; want just the terminologist's 0.25", usd) + } + r := priced(on, "/corpus") if usd := r.bookOnceUSD(); usd != 1.0 { - t.Fatalf("the book-level charge is the sum of the two budgets the plan is trimmed against, got %v want 1.0", usd) + t.Fatalf("with the classifier phase ON the charge is the sum of the two budgets the plan is trimmed against, got %v want 1.0", usd) } // …and it is added to the book ONCE, not smeared over the units — which is the whole reason the field // exists rather than being folded into expected_usd per unit. @@ -610,3 +629,67 @@ func TestAPipelineWithNoTranslatorStagePricesItsFirstStageAsTheTranslation(t *te t.Errorf("the unit reports no draft at all: %+v", dear) } } + +// TestASidecarFromAnOlderBuildStillYieldsAPrice builds the state acceptance found (V2-3) and no test in +// the tree could reach: a manifest that is CURRENT by every check the loader makes, and that predates the +// price and the structure. +// +// ⛔ WHY IT WAS UNREACHABLE BEFORE. `price` and `structure` are ADDITIVE, so manifestVersion deliberately +// did not move for them — moving it would discard every stored sidecar and re-cut every book. A file +// written by the previous build therefore passes the version, passes selfConsistent, passes the validity +// key, and comes back as «current» carrying no price. The read path took it, returned nil, and the +// fallback that the code itself calls the invariant never ran: the book reported NO PRICE AT ALL, +// silently, on the surface a buyer's platform reads before deciding. +// TestManifestServesTheReadModelsIdentically cannot see this — it compares «this pack's sidecar» with +// «no sidecar» and never builds the older FORM. +func TestASidecarFromAnOlderBuildStillYieldsAPrice(t *testing.T) { + rec := &reqRec{} + srv := newJSONProvider(rec, draftEdit) + defer srv.Close() + bookPath := setupProjectOpts(t, srv.URL, projectOpts{source: cjkSource(2, 1400)}) + r := newRunner(t, bookPath) + defer r.Close() + if _, err := r.BuildAndPersistManifest(); err != nil { + t.Fatal(err) + } + + // Age the sidecar: strip exactly the two additive fields and leave everything the loader validates + // untouched — the version, the counters and the key all still describe this book, cut this way. + path := r.ManifestPath() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var doc map[string]any + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + delete(doc, "price") + delete(doc, "structure") + aged, err := json.MarshalIndent(doc, "", " ") + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(aged, '\n'), 0o644); err != nil { + t.Fatal(err) + } + + // The loader must still consider it CURRENT — otherwise this test is about staleness, not about an + // older FORM, and it would pass for the wrong reason. + if m := r.loadManifest(); m == nil { + t.Fatal("fixture drifted: the aged sidecar must still validate, or this test is about a stale manifest instead of an old one") + } else if m.Price != nil || m.Structure != "" { + t.Fatalf("fixture drifted: the aged sidecar still carries the new fields (%+v / %q)", m.Price, m.Structure) + } + + rep, err := r.Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if rep.Price == nil || rep.Price.StepMaxUSD <= 0 { + t.Fatalf("a sidecar from an older build silenced the price: %+v — the accelerator changed the ANSWER, which is the one thing the fallback exists to prevent", rep.Price) + } + if rep.Structure == "" { + t.Error("…and it silenced the cut's provenance too, so an order phrased in chapters would be offered against a cut nobody described") + } +} diff --git a/backend/internal/pipeline/status.go b/backend/internal/pipeline/status.go index 6385ba8a..671c164f 100644 --- a/backend/internal/pipeline/status.go +++ b/backend/internal/pipeline/status.go @@ -324,9 +324,20 @@ type StatusReport struct { // consumer that showed one where it meant the other would be wrong in the expensive direction on the // day a book is bought. // - // Read from the persisted manifest rather than recomputed, so `status` and `manifest --json` cannot - // quote two different prices for one book. Absent when there is no current manifest — the fallback - // re-chunk path has the text but not the sidecar's guarantee that it describes THIS cut. + // ⛔ PRESENT WITHOUT A SIDECAR TOO, and the sentence that used to stand here said the opposite. It + // read «absent when there is no current manifest — the fallback re-chunk path has the text but not + // the sidecar's guarantee that it describes THIS cut», which described the code for as long as it + // took the same commit to add the fallback and then not come back here (acceptance F7). A consumer + // building on it would have concluded «no sidecar ⇒ no price» and shown a buyer nothing on precisely + // the surface the buyer asks the question from. + // + // What actually holds: `status` and `manifest --json` cannot quote two different prices for one book + // because BOTH ends run the same derivation (readModelPrice → projectBook), not because one of them + // declines to answer. The sidecar is an ACCELERATOR; when it cannot answer — absent, stale, or + // current but older than this field — the price is computed from the cut this read has just made, + // which describes THIS cut by construction rather than by guarantee. + // + // ABSENT means the runner could not resolve prices at all: no text to cut, or no price table. Price *BookPrice `json:"price,omitempty"` // Structure says whether the chapter cut was declared by the format, detected in the prose, or absent // — see BookManifest.Structure. Carried here because an order phrased in chapters is offered off this diff --git a/backend/internal/runevents/runevents.go b/backend/internal/runevents/runevents.go index b10e1be0..f00f7266 100644 --- a/backend/internal/runevents/runevents.go +++ b/backend/internal/runevents/runevents.go @@ -172,13 +172,56 @@ type Ceiling struct { // rounded UP (money never travels as a float, PD-79, and a figure a person tops up AGAINST must never // be short). // - // ⚠ IT DOES NOT WEAKEN «THE FACT AND NOTHING ELSE», it completes it. What this event may not carry is - // the STRUCTURE OF OUR COSTS — what a model charges, what a stage costs, what one call was priced at - // (D39.196 §2а keeps ПТ-33 alive while lifting the ban on money in the UI). A shortfall is none of - // those: it is the distance between a limit the platform itself set and a total it has already - // authorised, and it discloses nothing about what any one call cost. Publishing the DENIED ESTIMATE - // instead — the figure the engine already prints to stderr — would have published exactly the thing - // that is forbidden, which is why this is the shortfall. + // ⛔ RATIFIED BY THE OWNER — D39.203, 05.09: the engine MAY tell the platform how much to add. What + // the decision rests on is a distinction, and the distinction is what makes this field legitimate + // rather than tolerated: **what leaves is a PRE-CALL ESTIMATE — the reservation the engine asks for + // BEFORE it dials — and not a COST, what was actually charged after.** ПТ-35 bans the cost and stands + // UNNARROWED; a settlement still does not leave this process by any door. + // + // ⚠ HOW IT GOT DECIDED IS WORTH KEEPING, because two sessions in a row closed it wrongly on their own + // authority. An earlier version of this comment called it «an exception taken knowingly» and cited a + // ratification that does not exist in the decisions log; the version after that marked it + // `pending owner`, which was right. The ban is the OWNER's (ПТ-35, `docs/product-requirements.md`): + // revoking «no money on screen» on 05.09 left the rest standing in so many words — «цены моделей, + // стоимость стадий И ВЫЗОВОВ» — and the pack that ordered this field applied that ban to the WIRE, + // not to a screen (`docs/BACKEND_MONEYSTOP_SESSION_PROMPT.md:78`). So the measurement below never + // disproved this field's implementation; it disproved a PREMISE OF THE ORDER, and a premise is the + // owner's to re-decide, not a session's. + // + // ⚠ AND THE FORM WILL CHANGE: the owner chose «how much to add so ANY next call passes» + // (`max(shortfall, step_max − headroom)`), which makes `shortfall_micro_usd` a false name and carries + // a `StreamVersion` minor with it. That is a NEW ORDER, not a defect here — this field is not wrong, + // it is narrower: it says how much was missing for THIS call. D39.203 §5–6. + // + // ⛔ IT DOES DISCLOSE THE ESTIMATE OF THE REFUSED CALL — the thing D39.203 permits. An earlier + // version of this comment claimed the opposite — «discloses nothing about what any one call cost» — + // and acceptance disproved it with a live probe rather than an argument: the admission arithmetic is + // `committed + reserved + estimate > ceiling`, so a reader who knows the ceiling (the platform set it) + // and the cumulative committed spend (this stream publishes it — see Spend) recovers + // `estimate = shortfall + ceiling − committed − reserved`. On the ORDINARY terminal refusal the sky is + // empty by construction (the run stops through waitNothingInFlight), so `reserved` is nil-to-leftover + // and the recovered figure lands within a rounding micro-dollar of the engine's own + // `denied estimate=$…`. Measured, not reasoned. + // + // ⚠ AND THE ARGUMENT THAT WAS OFFERED FOR IT WAS FALSE TOO, so it is retired here rather than + // repeated: «the same door is already open through `step_max_usd`». No CONSUMER has it open — + // `git grep -E "StepMax|step_max" -- platform/` is EMPTY and the identifier appears nowhere in this + // package. But the ENGINE does publish it, because the same pack ordered it into `manifest --json`, + // and `step_max_usd` is by construction the estimate of ONE call — the most expensive single + // reservation the book can ask for. So the order itself holds both halves: item (3) forbids a call's + // price leaving, item (4) requires the largest one to be published. That tension is the owner's to + // resolve, and naming it is the honest form of «the same door». + // + // WHAT IS NOT DISCLOSED EITHER WAY, so that the open question stays the narrow one: the STRUCTURE of + // the costs — what a model charges, what a stage costs, how a call divides between prompt and + // completion — and what a call actually COST, since both this figure and `step_max_usd` are pre-call + // ESTIMATES, never a settlement. And what the field buys, which is why it was ordered: a stop that + // cannot say how much was missing leaves a buyer with a dead book and no next step — measured live on + // 04.09. + // + // ⚠ ONE PATH KEEPS THE PRICE: a stop taken through settleCannotHelp can leave calls IN FLIGHT, and + // then `reserved` is neither zero nor known to the consumer, so the subtraction yields a bound rather + // than the estimate. // // OMITTED when the engine cannot state it honestly: a `day` scope stop (that ceiling sums every book // in the store while the engine's figures are one book's), or a ledger read that failed at the moment @@ -241,7 +284,29 @@ type Finished struct { // Ratified with the disclosure law, D39.181 п.2; the presence rule widened when the engine learned not // to charge a grant twice for one unit (backlog row 232). Volume *VolumeLedger `json:"volume,omitempty"` - // Money is what a run stopped by a SPEND ceiling actually bought, present only on `outcome: ceiling`. + // Money is what a run whose SPEND CEILING WAS REACHED actually bought. + // + // ⛔ ITS PRESENCE RULE IS «A CEILING WAS REACHED», NOT «THE OUTCOME IS `ceiling`», AND THE DIFFERENCE + // IS THE WHOLE OF THIS PARAGRAPH. An earlier version of it said «present only on `outcome: ceiling`», + // which is what a consumer of THIS FILE would have built against — and both acceptance verifiers + // found it independently, which is the strongest signal a wrong sentence can get. The engine attaches + // it on FIVE terminal branches (pipeline/events.go), because a ceiling no longer cancels its siblings + // and a run can therefore latch on money and still depart as something else: + // + // - `ceiling` — the tidy case; + // - `failed` — an infra failure or a crash landed on top of a caught ceiling; + // - `stopped` — a caught SIGTERM after the money had already run out; + // - `bank_stop` — a signing stop on a book that had touched its ceiling. + // + // In every one of those the buyer's book is in the SAME state, so a ledger attached only to the tidy + // exit would be missing from exactly the messy ones a reader needs it for. + // + // ⚠ AND THE CONVERSE IS FALSE TOO, so do not build on it either: `outcome: ceiling` does NOT imply + // this field. It is absent when the run stopped before it learned the book's cut, because the + // counters it is made of are seeded from that cut and there is nothing honest to report without one. + // + // So: PRESENT means «a spend ceiling was reached in this run, and here is what it bought». ABSENT + // means «no ceiling, or no counters» — never «nothing was bought». // // ⛔ IT IS NOT `Volume` UNDER ANOTHER NAME, and the two must not be merged. A volume grant is decided // BEFORE the waves and its seven counters are a PLAN trued up afterwards; a spend ceiling stops the