diff --git a/backend/cmd/tmmutate/mutations.json b/backend/cmd/tmmutate/mutations.json index 6a7e46f9..dfd745b1 100644 --- a/backend/cmd/tmmutate/mutations.json +++ b/backend/cmd/tmmutate/mutations.json @@ -2881,5 +2881,31 @@ "replace": "\treturn m.providerReasoning(model) != \"\"" } ] + }, + { + "id": "BANKSTOP-the-sheets-order-is-the-stops-ranking", + "why": "the section's own contract says its order IS the stop's ranking, so which row an owner reads first is information rather than layout. Nothing asserted it until 08.09: the fold's one-row fixture made order unobservable by construction, and every other assertion found its row by Src instead of by position, so a reversal shipped a sheet ranked backwards and left the package green", + "package": "./internal/pipeline/", + "battery": true, + "edits": [ + { + "file": "internal/pipeline/bankexport.go", + "find": "\t\tout = append(out, p)\n\t}\n\treturn out\n}", + "replace": "\t\tout = append(out, p)\n\t}\n\tfor i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {\n\t\tout[i], out[j] = out[j], out[i]\n\t}\n\treturn out\n}" + } + ] + }, + { + "id": "BANKSTOP-nothing-consolidated-is-published-as-nothing", + "why": "Dst is \"\" when the role consolidated nothing — the field's comment says so in as many words — and that empty must reach the sheet. Substituting the source surface publishes 青茅山 -> 青茅山, a decision where none was made, in the one place whose whole purpose is to show what has yet to be decided. Invisible against any fixture whose rows all carry a rendering, which was every fixture in this package until 08.09", + "package": "./internal/pipeline/", + "battery": true, + "edits": [ + { + "file": "internal/pipeline/bankexport.go", + "find": "\t\tout = append(out, p)", + "replace": "\t\tif p.Dst == \"\" {\n\t\t\tp.Dst = row.Src\n\t\t}\n\t\tout = append(out, p)" + } + ] } ] diff --git a/backend/internal/pipeline/bankexport_test.go b/backend/internal/pipeline/bankexport_test.go index f88cf3b0..564925da 100644 --- a/backend/internal/pipeline/bankexport_test.go +++ b/backend/internal/pipeline/bankexport_test.go @@ -14,11 +14,19 @@ import ( // while the projection lies. Measured on 08.09: four mutations planted at once (Freq×10, Invented forced // false, Contradicts↔BankHolds swapped) left the whole package green in 16.8 s. // -// So the row below gives EVERY field a value that is distinguishable from every other field's: the two -// string lists differ in length AND in content, Freq/Spread/Conf are three different non-zero numbers, +// So the first row below gives EVERY field a value that is distinguishable from every other field's: the +// two string lists differ in length AND in content, Freq/Spread/Conf are three different non-zero numbers, // Kind and Channel are two different strings, and the rendering is one no draft proposed. +// +// And the table is TWO rows, not one, for two reasons a single row cannot carry. ORDER: the section's own +// contract (bankexport.go — "Order is the stop's own RANKING") says which row an owner reads first, and a +// one-row table makes a reversal unobservable by construction — measured 08.09, reversing the fold left +// the whole package green. NOTHING CONSOLIDATED: the second row's Dst is "", the state the field's comment +// names in as many words, and no fixture in this package carried one — so a fold substituting the source +// surface for the missing rendering published 青茅山 → 青茅山, a decision where there is none, and stayed +// green too. func TestEveryDecidingFieldSurvivesTheProjection(t *testing.T) { - row := BankStopRow{ + decided := BankStopRow{ Src: "方源", Dst: "Фан-Юань", Type: "name", @@ -46,28 +54,52 @@ func TestEveryDecidingFieldSurvivesTheProjection(t *testing.T) { Signals: []string{"majority"}, } - got := projectBankProposals([]BankStopRow{row}) - if len(got) != 1 { - t.Fatalf("one row projected to %d proposal(s)", len(got)) - } - want := BankExportProposal{ - Src: "方源", Dst: "Фан-Юань", Kind: "name", Channel: "banknote", - Freq: 37, Spread: 4, Conf: 61, Invented: true, - Contradicts: []string{`this run also renders 方 as "Фан"`}, - BankHolds: []string{ - `unsigned draft "方源"→"Странник" [ch 1..20]`, - `approved "方源"→"Фан Юань" [ch 21..40]`, + // The row where the role consolidated NOTHING. Dst stays empty all the way to the sheet: an owner + // reading 青茅山 → 青茅山 would be reading a decision that was never made. Conf is negative because + // the role stated none, which the field's comment calls a different fact from "0% sure". + undecided := BankStopRow{ + Src: "青茅山", + Dst: "", + Type: "place", + Origin: "mined", + Freq: 5, + Spread: 2, + Conf: -1, + Variants: []BankStopVariant{ + {Dst: "гора Цинмао", Chunks: 3}, }, - Variants: []string{"Фан Юань ×9", "Фан ×2 (proposed for 方)"}, - } - if !reflect.DeepEqual(got[0], want) { - t.Errorf("the projection is not the row it projects:\n got %+v\n want %+v", got[0], want) } - // AND: no field of the proposal is left at its zero value. The comparison above pins the fields that - // exist today; this pins the ones added tomorrow — a field appended to BankExportProposal and never - // filled by the fold ships as an absent key in the sidecar, and every assertion written against - // today's fields stays green while the signing screen loses a column. + got := projectBankProposals([]BankStopRow{decided, undecided}) + want := []BankExportProposal{ + { + Src: "方源", Dst: "Фан-Юань", Kind: "name", Channel: "banknote", + Freq: 37, Spread: 4, Conf: 61, Invented: true, + Contradicts: []string{`this run also renders 方 as "Фан"`}, + BankHolds: []string{ + `unsigned draft "方源"→"Странник" [ch 1..20]`, + `approved "方源"→"Фан Юань" [ch 21..40]`, + }, + Variants: []string{"Фан Юань ×9", "Фан ×2 (proposed for 方)"}, + }, + { + Src: "青茅山", Dst: "", Kind: "place", Channel: "mined", + Freq: 5, Spread: 2, Conf: -1, + Variants: []string{"гора Цинмао ×3"}, + }, + } + // Compared as a WHOLE SLICE, not row by row found by Src: the ranking is the information, so the + // position each row holds is part of what is being asserted. + if !reflect.DeepEqual(got, want) { + t.Fatalf("the projection is not the table it projects:\n got %+v\n want %+v", got, want) + } + + // AND: no field of the proposal is left at its zero value — read off the FIRST row, the one built to + // have every field set (the second is deliberately half empty, that being its whole point). The + // comparison above pins the fields that exist today; this pins the ones added tomorrow — a field + // appended to BankExportProposal and never filled by the fold ships as an absent key in the sidecar, + // and every assertion written against today's fields stays green while the signing screen loses a + // column. // // ⚠ Its limit, named: this catches a field the PROPOSAL grew and the fold ignored. A field BankStopRow // grows that ought to be projected and is not cannot be caught mechanically — whether a new fact is diff --git a/backend/internal/pipeline/miningstop_join_test.go b/backend/internal/pipeline/miningstop_join_test.go index 088abbf7..c66a0f35 100644 --- a/backend/internal/pipeline/miningstop_join_test.go +++ b/backend/internal/pipeline/miningstop_join_test.go @@ -72,6 +72,24 @@ type miningStopOpts struct { // candidate (the join's non-empty intersection — the whole point of the fixture) and one that is not. const bankBlockForMining = bankSeparator + "\n方源\tФан Юань\tname\n青茅山\tгора Цинмао\tplace" +// srcsOfProposals and srcsOfRows render the two orders side by side when they disagree: a failure that +// says "row 2 differs" without showing both sequences sends the reader back to the debugger. +func srcsOfProposals(ps []BankExportProposal) []string { + out := make([]string, 0, len(ps)) + for _, p := range ps { + out = append(out, p.Src) + } + return out +} + +func srcsOfRows(rows []BankStopRow) []string { + out := make([]string, 0, len(rows)) + for _, r := range rows { + out = append(out, r.Src) + } + return out +} + // setupMiningStopProject builds a zh→ru fixture with the bank-mining stop CONFIGURED (contrast artifact + // langpack) and the banknote channel ON, so both halves of the WHAT→WHICH join are live. Local // constructor by the repair_integration_test.go precedent: the shared setupProjectOpts stays untouched. @@ -969,6 +987,16 @@ func TestBankStopPublishesItsProposalsInTheReadOut(t *testing.T) { if want := projectBankProposals(stop.Rows); !reflect.DeepEqual(exp.Proposed, want) { t.Fatalf("the published section is not the fold of the table it came from:\n in the file %+v\n from the rows %+v", exp.Proposed, want) } + // ORDER, anchored against the ROWS rather than against the fold — the one thing the comparison above + // structurally cannot see. The section's contract is that its order IS the stop's ranking, so which + // row an owner reads first is information and not layout; a fold that reversed it would pass every + // comparison that finds its row by Src instead of by position. + for i := range exp.Proposed { + if exp.Proposed[i].Src != stop.Rows[i].Src { + t.Fatalf("published row %d is %q, the stop ranked %q there — the sheet's order is the ranking, and this one is not it:\n file %v\n rows %v", + i, exp.Proposed[i].Src, stop.Rows[i].Src, srcsOfProposals(exp.Proposed), srcsOfRows(stop.Rows)) + } + } var fy *BankExportProposal for i := range exp.Proposed { if exp.Proposed[i].Src == "方源" { diff --git a/docs/PROGRESS.md b/docs/PROGRESS.md index 2b250614..a1f15d7e 100644 --- a/docs/PROGRESS.md +++ b/docs/PROGRESS.md @@ -699,6 +699,24 @@ **Две мелочи.** `snapshot_wave_test.go` говорил «four callers», соседний `snapshot.go` — «five call sites in four test files»: оба верны, единица не названа; теперь единица названа в обоих. `backend/README.md` печатал диапазон цены гейта `87–104 %`, снятый на СТАРОМ составе из 11 записей, и об этом не говорил — пере-снят формулировкой без процента, вместе с отзывом моего же числа «123 %» (см. поправку выше по секции: парный замер спина-к-спине дал 102 %, то есть 123 % были фоновой нагрузкой, а не свойством цели). +#### ТРЕТИЙ (и по правилу остановки ПОСЛЕДНИЙ) ДОФИКС — порядок секции и «ничего не консолидировано» (08.09, к акту `D39.225`) + +Две линзы независимо нашли две выживших мутации в свёртке предложений. Пере-мерены моей рукой на чистых копиях: **разворот порядка** — `ok` 15,8 с; **`if p.Dst == "" { p.Dst = row.Src }`** — `ok` 16,1 с. Обе настоящие, и корень у них один: тест свёртки проецировал ОДНУ строку, поэтому порядок был ненаблюдаем по построению, а фикстуры пакета все несли непустой `Dst`, поэтому класс «роль не консолидировала ничего» был невидим целиком. + +Бьёт это по свойству, которое код объявляет своими словами (`bankexport.go`: «Order is the stop's own RANKING… which row to read first is the information here») и которое не утверждал ни один тест; и по классу, который комментарий поля называет дословно («`""` when nothing was consolidated»). На листе подписи вторая мутация показала бы `青茅山 → 青茅山` — исходную поверхность как рендеринг ровно там, где решения нет. + +**Лечение.** Тест свёртки проецирует ТАБЛИЦУ ИЗ ДВУХ строк в известном ранжировании и сверяет **срез целиком**, а не найденную перебором строку: позиция — часть утверждения. Вторая строка — та, где роль не решила ничего (`Dst: ""`, `Conf: -1` — «роль не назвала уверенности» есть другой факт, чем «уверена на 0 %»). Рефлективный страж нулевых полей остался на ПЕРВОЙ строке и это сказано на месте: вторая полупуста намеренно, в этом её смысл. + +**И порядок отдельно закреплён в интеграционном — против стоп-СТРОК, а не против свёртки.** Сверка «секция есть свёртка той же таблицы» структурно слепа к развороту: оба берега едут вместе. Добавлен цикл, сверяющий `i`-е опубликованное предложение с `i`-й строкой стопа, и при расхождении печатающий обе последовательности целиком — «строка 2 не та» без обеих раскладок отправляет читателя в отладчик. + +**Предъявлено ИСПОЛНЕНИЕМ, по одной посадке на чистой копии:** разворот → краснеет НА ОБОИХ уровнях (`bankexport_test.go:94` «the projection is not the table it projects» и `miningstop_join_test.go:996` «published row 0 is "青茅山", the stop ranked "方源" there»); пустой `Dst` → краснеет на юнит-тесте. ⚠ Вторую интеграционный НЕ ловит и поймать не может: в его фикстуре нет строки с пустым `Dst`, а его сверка — свёртка против самой себя. Разделение то же, что и кругом раньше, и названо там же. + +**Каталог: 230 → 232** (+2, `battery` 19 → 21): `BANKSTOP-the-sheets-order-is-the-stops-ranking` и `BANKSTOP-nothing-consolidated-is-published-as-nothing`. Битых якорей 0 при 232 записях. + +**Числа, снятые ПОСЛЕ последней правки (порядок — мутации, затем батарея; между ними дерево не тронуто).** `make mutations` → exit 0, **5m39.4s**, 21 запись, 21 RED, выживших **0**, `0 unexpected outcome(s)`, `anchors swept: 0 of 232 entr(ies) rotten`. `make battery` → exit 0, **3m17.2s**, `0 issues`, `ok` 19 / `FAIL` 0 / «no test files» 4, скипов 4 — те же. Тестов в зоне **1267** (два новых теста свёртки; третий дофикс имён не добавил, он пере-сделал существующий). + +⛔ **Правило остановки применено оркестратором, и я его принимаю без спора.** Три круга подряд находили в одной новой секции всё более узкие вещи; у секции сегодня ноль читателей, и цена следующего круга растёт быстрее, чем то, что он покупает. Найденное после этого дофикса идёт СТРОКОЙ бэклога, а не работой: «контракт секции предложений запинен по полям и порядку, но не исчерпывающе». + ### Пак «ЗАКОН БАНКА — ЧЕСТНО» (06.09, промт `docs/BACKEND_CONSISTENCY_SESSION_PROMPT.md`, вход HEAD `e4097cb`). НЕ КОММИЧУ — ждёт лендинга **Итог: закон банка описан тем, что код делает, и найдена дыра ШИРЕ заказанной — стадия схлопывания не сверяла свою выработку со строкой банка на тот же ключ ни при какой подписи.** Закрыто в двух местах (схлопывание + страховка на слиянии), репортом, без смены поведения платного прогона. **ШЕСТЬ кругов самопроверки нашли 43 находки В МОЕЙ ЖЕ работе** (18 + 8 + 5 + 6 + 4 + 2); круг 6 сказал «сошлись» — новых находок в коде пака нет. Шесть находок — ложные утверждения в моих же комментариях, то есть ровно тот класс, который пак чинил; две внесены лечением предыдущего круга; один и тот же шов «писатель ↔ читатель» ловился ТРИ круга подряд, после чего был убран целиком, а не подпёрт четвёртой заплатой. Все разобраны, списки ниже.