Take in the engine zone's bank-completeness and money work as the zone built it: the signer sees an incomplete bank, and the run total agrees with the ledger

This commit is contained in:
heaven 2026-09-08 14:28:06 +03:00
parent e123c2b7a8
commit 2b6f1f46be
21 changed files with 1783 additions and 68 deletions

View file

@ -0,0 +1,184 @@
package main
import (
"bytes"
"fmt"
"strings"
"testing"
"textmachine/backend/internal/pipeline"
)
// bankcompleteness_render_test.go: the SCREEN half of backlog row 253(б) and row 357(а). The bank's
// completeness and the rows the paid role was never asked about reach the surface an owner reads FIRST —
// stdout — and not only the machine sidecar and the full text table beside the database.
// TestTheSigningScreenSaysHowCompleteTheBankIs pins all four states of one conditional line. Three of them
// are the states the engine can be in; the fourth is the false alarm the naive version raises.
//
// ⛔ EACH CASE ASSERTS A SENTENCE AND FORBIDS THE OTHER TWO. A pin that only checks the alarm appears is
// green on a screen that shows it always, and a pin that only checks silence is green on a screen that
// says nothing at all — this project has bought both mistakes.
func TestTheSigningScreenSaysHowCompleteTheBankIs(t *testing.T) {
const (
whole = "Bank completeness: WHOLE"
partial = "Bank completeness: PARTIAL"
unmeasured = "Bank completeness: NOT MEASURED"
types = "Term TYPES are unrefined"
)
for _, tc := range []struct {
name string
c *pipeline.BankConsolidation
want []string
mustNotSay []string
}{{
name: "the pass never ran",
c: nil,
want: []string{unmeasured, "did not run"},
mustNotSay: []string{whole, partial, types},
}, {
name: "every render batch was bought",
// Three DIFFERENT numbers, and all three asserted: equal ones would let the line print any of
// them in any slot and still pass, which is the same degeneracy the money fixtures guard against.
c: &pipeline.BankConsolidation{Complete: true, Consolidated: 41, Declined: 2, Unanswered: 3},
want: []string{whole, "41 consolidated", "2 declined", "3 unanswered"},
mustNotSay: []string{partial, unmeasured, types},
}, {
name: "the paid role was asked for nothing",
// Reachable state, not a hypothetical: the already-banked filter can empty the paid set, and then
// zero batches were planned. «Every render batch was bought» is true of zero batches and reads as a
// report on work that happened — the instrument answering its own question (D39.202).
c: &pipeline.BankConsolidation{Complete: true, NeverAsked: 3},
want: []string{whole, "asked for nothing", "No batch was planned"},
mustNotSay: []string{partial, unmeasured, types, "every render batch was bought"},
}, {
name: "a budget cut the render pass",
c: &pipeline.BankConsolidation{
Complete: false, RenderBatchesDropped: 5, Consolidated: 38, Unanswered: 47,
},
// The count of unbought batches AND the warning that `unanswered` is not the role's silence here:
// on the live run of 04.09 those 47 read as terms the model skipped.
// ⚠ THE BUDGET IS NAMED IN FULL, because `budget_usd` is a SUBSTRING of `classify_budget_usd`:
// asserted loosely, this case stays green while the screen sends an operator to raise the budget
// that did not cut his pass. The forbidden list says the other budget must not appear at all here.
want: []string{partial, "5 batch(es)", "47 unanswered", "38 consolidated", "gates.terminology.budget_usd"},
mustNotSay: []string{whole, unmeasured, types, "classify_budget_usd"},
}, {
name: "only the classifier was cut",
c: &pipeline.BankConsolidation{
Complete: true, ClassifyBatchesDropped: 1, Consolidated: 29,
},
// The run of 08.09: the render pass was intact and the engine's own log still said «PARTIALLY
// consolidated». The screen must not repeat that — the renderings are all there.
want: []string{whole, types, "gates.terminology.classify_budget_usd", "1 batch(es)"},
mustNotSay: []string{partial, unmeasured},
}} {
t.Run(tc.name, func(t *testing.T) {
var b bytes.Buffer
renderBankConsolidation(&b, tc.c)
out := b.String()
for _, want := range tc.want {
if !strings.Contains(out, want) {
t.Errorf("the screen must say %q:\n%s", want, out)
}
}
for _, never := range tc.mustNotSay {
if strings.Contains(out, never) {
t.Errorf("the screen must NOT say %q here:\n%s", never, out)
}
}
})
}
}
// TestTheNeverAskedCountSurvivesTheStdoutCap is the trap this surface was built around, and the fixture is
// deliberately WIDER than the cap because a narrower one is green on a blind screen.
//
// A row the role was never asked about carries no rendering, so reviewRank sends it to 1000 — dead LAST —
// and the stdout table shows twenty rows. On any book with twenty candidates every such row is below the
// fold, so a per-row mark alone would tell the owner nothing at the moment he decides. The count sits
// above the table, outside the cap.
//
// The test asserts BOTH halves, and the second is what makes the first mean something: the marked row
// really is cut from the table here.
//
// Mutation this catches: move the summary inside renderBankStopRows (or drop the NeverAsked clause) and
// the count disappears from a screen whose table cannot show the rows it counts → RED.
func TestTheNeverAskedCountSurvivesTheStdoutCap(t *testing.T) {
var rows []pipeline.BankStopRow
for i := 0; i < bankStopStdoutCap+5; i++ {
// Every one of these has a rendering and a confidence, so all of them outrank the settled row below.
rows = append(rows, pipeline.BankStopRow{
Src: fmt.Sprintf("术%d", i), Dst: fmt.Sprintf("приём%d", i), Origin: "mined", Freq: 5, Conf: 10,
})
}
rows = append(rows, pipeline.BankStopRow{Src: "方源", Origin: "banknote", Freq: 30, SettledByBank: true})
var b bytes.Buffer
renderSignatureStop(&b, &pipeline.WaveSignatureStop{
Terms: len(rows), SignaturePath: "p", Rows: rows,
Consolidation: &pipeline.BankConsolidation{Complete: true, Consolidated: 25, NeverAsked: 1},
})
out := b.String()
if !strings.Contains(out, "1 term(s) were NOT ASKED about") {
t.Errorf("the count of never-asked rows must reach the screen above the cap:\n%s", out)
}
// ABOVE the table, which the code claims in a comment and nothing else checked — an invariant living
// only in prose is held by nothing. The owner meets the state of the bank before the rows, not after
// scrolling past twenty of them.
// ⚠ BOTH INDICES ARE CHECKED FOR PRESENCE FIRST. `strings.Index` returns -1 for an absent needle, and
// -1 < N is true for every N — so the bare comparison passes loudest exactly when the line has vanished.
iSummary, iTable := strings.Index(out, "Bank completeness"), strings.Index(out, "least-confident first")
if iSummary < 0 || iTable < 0 {
t.Fatalf("both the completeness line and the table must be on the screen (summary=%d table=%d):\n%s", iSummary, iTable, out)
}
if iSummary > iTable {
t.Errorf("the completeness line must come BEFORE the table it qualifies:\n%s", out)
}
// F7: the SENTENCE, not only the number. A count with no explanation puts good news in the same shape
// as a gap, which is what the whole marker exists to prevent.
if !strings.Contains(out, "the bank already renders those surfaces and every draft agreed") {
t.Errorf("the count must carry WHY those rows were skipped, not just how many:\n%s", out)
}
// The half that proves the fixture: the row itself is NOT on the screen, so the count is the only thing
// carrying it. If this ever starts failing because the cap or the ranking moved, the assertion above
// stopped being a test of the cap and has to be rewritten, not relaxed.
if strings.Contains(out, "方源") {
t.Fatalf("premise broken: the settled row is supposed to fall under the stdout cap in this fixture, "+
"so a per-row mark alone would be invisible — rewrite the fixture, do not relax the assertion:\n%s", out)
}
// CONTROL: shown a settled row that FITS, the table does mark it — so the count above is a second
// carrier for the capped case and not a replacement that left the row unexplained.
var small bytes.Buffer
renderSignatureStop(&small, &pipeline.WaveSignatureStop{
Terms: 1, SignaturePath: "p",
Rows: []pipeline.BankStopRow{{Src: "方源", Origin: "banknote", Freq: 30, SettledByBank: true}},
Consolidation: &pipeline.BankConsolidation{Complete: true, Consolidated: 1, NeverAsked: 1},
})
if !strings.Contains(small.String(), "NOT ASKED: the bank already renders this surface") {
t.Errorf("a settled row that fits on the screen must say why it has no rendering:\n%s", small.String())
}
// ⛔ AND THE LINE DOES NOT LIVE AND DIE WITH THE TABLE. renderBankStopRows returns early on zero rows,
// so a summary printed from inside it — or guarded by the rows being non-empty — disappears exactly
// where the state still matters: a stop whose table this build could not fill still put a bank in front
// of the owner. Without this case, «outside the cap» is asserted only by ordering.
var noRows bytes.Buffer
renderSignatureStop(&noRows, &pipeline.WaveSignatureStop{
Terms: 0, SignaturePath: "p",
Consolidation: &pipeline.BankConsolidation{RenderBatchesDropped: 2, Consolidated: 7, Unanswered: 9},
})
if !strings.Contains(noRows.String(), "Bank completeness: PARTIAL") {
t.Errorf("the completeness line is not a part of the table and must survive an empty one:\n%s", noRows.String())
}
// And a row that was ASKED about prints no such line, or the marker means nothing.
var quiet bytes.Buffer
renderSignatureStop(&quiet, &pipeline.WaveSignatureStop{
Terms: 1, SignaturePath: "p",
Rows: []pipeline.BankStopRow{{Src: "花家", Dst: "семья Хуа", Origin: "mined", Freq: 9, Conf: 50}},
Consolidation: &pipeline.BankConsolidation{Complete: true, Consolidated: 1},
})
if strings.Contains(quiet.String(), "NOT ASKED") {
t.Errorf("control: a row the role WAS asked about must carry no such mark:\n%s", quiet.String())
}
}

View file

@ -170,6 +170,7 @@ func renderSignatureStop(w io.Writer, s *pipeline.WaveSignatureStop) {
// (review-workflow finding: run 2 of the flag chain stops with 2 terms of which 1 is new).
fmt.Fprintf(w, "=== BANK-MINING: STOP FOR SIGNATURE (%d term(s) in the map) ===\n", s.Terms)
fmt.Fprintf(w, "The run STOPPED before the edit wave: the mined delta holds term(s) no stop has shown you before.\n")
renderBankConsolidation(w, s.Consolidation)
renderBankStopRows(w, s.Rows)
fmt.Fprintf(w, "Signature map: %s\n", s.SignaturePath)
if s.TablePath != "" {
@ -187,6 +188,58 @@ func renderSignatureStop(w io.Writer, s *pipeline.WaveSignatureStop) {
fmt.Fprintln(w, "To run WITHOUT this pause, drop --verify-bank: the run then carries the unsigned bank forward and uses it.")
}
// renderBankConsolidation states how complete the bank being signed is, ABOVE the table and never inside
// it (backlog row 253б).
//
// ⛔ THE POSITION IS THE POINT, not layout. The table below is capped at 20 rows and ordered least-sure
// first, which puts a row with no rendering — every row a cut budget left unbought, and every row the bank
// already settled — at rank 1000, i.e. LAST. On a book with twenty candidates the whole class the owner
// most needs to see is exactly the class that falls off the bottom, so a per-row mark alone would be a
// green fixture and a blind screen. This line is outside the cap and cannot be lost.
//
// It always says something. A screen that is silent when the bank is whole makes its own silence carry
// meaning, which no reader can distinguish from the screen not knowing — and «did not measure» is a third
// state here, not a shade of «complete».
func renderBankConsolidation(w io.Writer, c *pipeline.BankConsolidation) {
if c == nil {
fmt.Fprintln(w, "Bank completeness: NOT MEASURED — the paid terminology pass did not run for this stop, "+
"so nothing here says whether the renderings below are all the book has.")
return
}
switch {
case c.Complete && c.Consolidated+c.Declined+c.Unanswered == 0:
// ⛔ THE PAID ROLE WAS ASKED NOTHING — every candidate was already settled by the bank, so no batch
// was ever planned. «Every render batch was bought» is technically true of zero batches and reads as
// a report on work that happened, which is the exact shape this surface exists to remove: an
// instrument answering its own question (D39.202). Reachable whenever the already-banked filter
// empties the paid set.
fmt.Fprintln(w, "Bank completeness: WHOLE — the paid role was asked for nothing, because the bank "+
"already renders every candidate this run found. No batch was planned and none was bought.")
case c.Complete:
fmt.Fprintf(w, "Bank completeness: WHOLE — every render batch was bought (%d consolidated, %d declined, %d unanswered).\n",
c.Consolidated, c.Declined, c.Unanswered)
default:
// The money, said as money: these terms are not undecided by the role, they were never offered to it.
fmt.Fprintf(w, "⚠ Bank completeness: PARTIAL — a budget cut the render pass and %d batch(es) were never "+
"bought, so some terms were never offered to the role at all (%d consolidated, %d declined, %d "+
"unanswered — the last figure counts the unbought terms together with the ones the role saw and "+
"skipped). Raise `gates.terminology.budget_usd` and re-run to finish the bank.\n",
c.RenderBatchesDropped, c.Consolidated, c.Declined, c.Unanswered)
}
if c.ClassifyBatchesDropped > 0 {
// A DIFFERENT fact from the one above, on a different budget: the bank can be whole and this still
// non-zero. Merging the two would raise a false alarm about the bank on every classify-only cut.
fmt.Fprintf(w, " Term TYPES are unrefined: the classifier's budget cut %d batch(es) — the renderings "+
"above are unaffected; raise `gates.terminology.classify_budget_usd`.\n", c.ClassifyBatchesDropped)
}
if c.NeverAsked > 0 {
// The saving, and it is stated because an empty rendering in the table below is the same glyph as
// «the role declined» and «the budget did not reach it» — three facts, one of which is good news.
fmt.Fprintf(w, " %d term(s) were NOT ASKED about: the bank already renders those surfaces and every "+
"draft agreed — nothing to decide, and they are marked in the table and the full sidecar.\n", c.NeverAsked)
}
}
// bankStopStdoutCap bounds the stdout table. The emission cap is 200 terms (miner.emitRankCap), and 200
// blocks of source contexts is a scroll, not a review surface — the FULL table always lands in the
// sidecar, and the banner says so, so the cap can never read as "that was all of it".
@ -223,6 +276,13 @@ func renderBankStopRows(w io.Writer, rows []pipeline.BankStopRow) {
}
fmt.Fprintf(w, " why: %s\n", trunc(why, 90))
}
if r.SettledByBank {
// The row prints an empty dst, which on this sheet is the glyph of three other facts — the role
// declined, no reply covered it, the budget never reached it — and this one is «nothing to
// decide». The same sentence the text sidecar carries, so the two cannot describe a row
// differently. The count above is what survives the cap; this is what a shown row says.
fmt.Fprintln(w, " NOT ASKED: the bank already renders this surface and every draft agreed — nothing to decide")
}
if len(r.Contradicts) > 0 {
// A contradiction against this run's OWN consolidations: the compound dropped the rendering the
// same reply gave its part. Loudest line of the row — it is a canon breaking inside one call.

View file

@ -1011,8 +1011,8 @@
"edits": [
{
"file": "internal/store/ledger.go",
"find": "\t\t c.escalation\n\t\tFROM checkpoints c JOIN jobs j ON j.id = c.job_id",
"replace": "\t\t 0\n\t\tFROM checkpoints c JOIN jobs j ON j.id = c.job_id"
"find": "\t\t c.cost_usd, c.escalation\n\t\tFROM checkpoints c JOIN jobs j ON j.id = c.job_id",
"replace": "\t\t c.cost_usd, 0\n\t\tFROM checkpoints c JOIN jobs j ON j.id = c.job_id"
}
]
},
@ -3290,5 +3290,243 @@
"replace": "if res.BankSettled > 0 && paidBefore {"
}
]
},
{
"id": "BANKMONEY-the-run-total-drops-the-classifier",
"why": "the run's TOTAL is built from the terminologist's RENDER cost alone, and the classifier settles on its own cost axis under its own budget — so the figure an operator reads while deciding whether to keep paying is short by the whole classify phase. Measured on the live run of 08.09: two thirds of the terminology phase's money never reached the total. The pin compares the total against the ledger's own sum, on a fixture where the two phases deliberately cost DIFFERENT amounts, because the fake model bills a fixed price per call and equal costs would let the wrong addend pass",
"package": "./internal/pipeline/",
"run": "TestTheRunTotalIsThisRunsSpendIncludingTheClassifier",
"battery": true,
"edits": [
{
"file": "internal/pipeline/waverun.go",
"find": "\t\tres.TotalUSD += t.CostUSD + t.ClassifyCostUSD",
"replace": "\t\tres.TotalUSD += t.CostUSD"
}
]
},
{
"id": "BANKMONEY-the-two-bank-roles-share-one-position",
"why": "the bank roles are checkpointed under ONE synthetic stage at chapter 0 and number their batches in chunk_idx, so the classifier's batch 0 and the terminologist's batch 0 arrive at the same (chapter, chunk, stage) triple. Keyed on that triple alone the earlier of the two is filed as SUPERSEDED — money that bought nothing — and on the live run of 08.09 that was the classifier's $0.009013, the largest of the three ledger rows, reported to the operator as a loss while it was bought and used",
"package": "./internal/pipeline/",
"run": "TestTheLedgerSaysTheClassifierBoughtTheBank|TestTwoBankRolesInOneBatchAreTwoPositions",
"battery": true,
"edits": [
{
"file": "internal/pipeline/paidtail.go",
"find": "\tif u.Stage == terminologyStageName {\n\t\tp.role = u.Role\n\t}\n",
"replace": ""
}
]
},
{
"id": "BANKMONEY-the-role-column-leaves-the-ledger-query",
"why": "the decomposition can only tell the two bank roles apart if the role reaches it, and it reaches it through this SELECT. Dropping the column leaves every row's role empty, which is exactly the pre-fix collision with no visible cause — the struct still has the field and the code still reads it",
"package": "./internal/pipeline/",
"run": "TestTheLedgerSaysTheClassifierBoughtTheBank",
"battery": true,
"edits": [
{
"file": "internal/store/ledger.go",
"find": "SELECT j.chapter, c.chunk_idx, c.stage, c.role, c.model_requested",
"replace": "SELECT j.chapter, c.chunk_idx, c.stage, '' AS role, c.model_requested"
}
]
},
{
"id": "BANKMONEY-the-role-enters-every-position",
"why": "the OTHER side of the same fix, and the one that would land a second money change unannounced. A repair call carries its stage's REAL name and its own role (repair.go), so folding the role into every position separates a repair from the stage call it repairs and quietly moves that money out of `superseded`. The role belongs to the bank stage alone, where the triple is not an address",
"package": "./internal/pipeline/",
"run": "TestTheRoleSplitDoesNotMoveARepairsMoney",
"battery": true,
"edits": [
{
"file": "internal/pipeline/paidtail.go",
"find": "\tif u.Stage == terminologyStageName {\n\t\tp.role = u.Role\n\t}\n",
"replace": "\tp.role = u.Role\n"
}
]
},
{
"id": "BANKCOMPLETE-a-boundary-that-measured-nothing-claims-a-whole-bank",
"why": "the bank read-out is written at FIVE boundaries and the terminology pass runs at ONE, so a section published unconditionally answers «consolidated 0, unanswered 0» — which a consumer reads as «nothing is missing» — about a bank nobody looked at. This is the D39.202 class: the instrument answering its own question. The nil is the whole distinction",
"package": "./internal/pipeline/",
"run": "TestABoundaryThatMeasuredNothingSaysNothing",
"battery": true,
"edits": [
{
"file": "internal/pipeline/bankexport.go",
"find": "\tif t == nil {\n\t\treturn nil\n\t}\n\treturn &BankConsolidation{",
"replace": "\tif t == nil {\n\t\tt = &terminologyResult{}\n\t}\n\treturn &BankConsolidation{"
}
]
},
{
"id": "BANKCOMPLETE-a-classifier-cut-is-read-as-a-partial-bank",
"why": "the engine's own log warning fires on EITHER counter and said «PARTIALLY consolidated» on the run of 08.09, where the render pass was intact (batches_dropped=0) and only the classifier's budget cut a batch. Copying that rule into the projection makes a whole bank read as partial and sends an owner to re-buy renderings he already has — a false alarm built by the cure. The bank's completeness is the RENDER pass; the classifier's cut is the term TYPES and travels apart",
"package": "./internal/pipeline/",
"run": "TestAClassifierCutIsNotAnIncompleteBank",
"battery": true,
"edits": [
{
"file": "internal/pipeline/bankexport.go",
"find": "\t\tComplete: t.BatchesDropped == 0,",
"replace": "\t\tComplete: t.BatchesDropped == 0 && t.ClassifyBatchesDropped == 0,"
}
]
},
{
"id": "BANKCOMPLETE-the-read-out-stops-carrying-the-completeness",
"why": "the counters exist and the projection exists, and the artifact the signing screen is built from still does not carry them unless this line runs — which is precisely the state row 253(б) describes: the engine knowing and the reader not seeing",
"package": "./internal/pipeline/",
"run": "TestABankCutByABudgetSaysSoInTheReadOut",
"battery": true,
"edits": [
{
"file": "internal/pipeline/bankexport.go",
"find": "\texp.Consolidation = projectBankConsolidation(r.lastTerminology)\n",
"replace": ""
}
]
},
{
"id": "BANKCOMPLETE-the-stop-screen-is-handed-no-completeness",
"why": "the CLI renders what the stop hands it, so dropping the field on the stop leaves the screen able to print the state and never given one — and the owner reads THIS surface first, before either sidecar",
"package": "./internal/pipeline/",
"run": "TestTheSignatureStopCarriesTheCompletenessToTheScreen",
"battery": true,
"edits": [
{
"file": "internal/pipeline/waverun.go",
"find": "\t\t\tConsolidation: projectBankConsolidation(r.lastTerminology),\n",
"replace": ""
}
]
},
{
"id": "BANKCOMPLETE-the-never-asked-count-moves-under-the-stdout-cap",
"why": "a row the role was never asked about carries no rendering, so reviewRank sends it to 1000 — LAST — and the stdout table shows twenty rows: on any real book the whole class falls off the bottom. Leaving the per-row mark as the ONLY carrier makes the screen green on a narrow fixture and blind on a live one, which is the trap this surface was built around. ⚠ The first version of this entry swapped the summary and the table instead, and it SURVIVED — rightly: moving the line does not remove it, and a mutation that attacks nothing is a green record of a property nobody guards",
"package": "./cmd/tmctl/",
"run": "TestTheNeverAskedCountSurvivesTheStdoutCap",
"battery": true,
"edits": [
{
"file": "cmd/tmctl/render.go",
"find": "\tif c.NeverAsked > 0 {\n\t\t// The saving, and it is stated because an empty rendering in the table below is the same glyph as\n\t\t// «the role declined» and «the budget did not reach it» — three facts, one of which is good news.\n\t\tfmt.Fprintf(w, \" %d term(s) were NOT ASKED about: the bank already renders those surfaces and every \"+\n\t\t\t\"draft agreed — nothing to decide, and they are marked in the table and the full sidecar.\\n\", c.NeverAsked)\n\t}\n",
"replace": ""
}
]
},
{
"id": "BANKCOMPLETE-the-whole-bank-says-nothing",
"why": "a screen silent when the bank is whole makes its own silence carry meaning, and no reader can tell that silence from the screen not knowing — while «did not measure» is a THIRD state here, not a shade of complete. A pin that only asserts the alarm sounds stays green on a surface that has stopped saying anything in the good case",
"package": "./cmd/tmctl/",
"run": "TestTheSigningScreenSaysHowCompleteTheBankIs",
"battery": true,
"edits": [
{
"file": "cmd/tmctl/render.go",
"find": "\tcase c.Complete:\n\t\tfmt.Fprintf(w, \"Bank completeness: WHOLE — every render batch was bought (%d consolidated, %d declined, %d unanswered).\\n\",\n\t\t\tc.Consolidated, c.Declined, c.Unanswered)\n\tdefault:",
"replace": "\tcase c.Complete:\n\t\t_ = c.Consolidated\n\tdefault:"
}
]
},
{
"id": "BANKMONEY-the-run-total-reports-the-contours-cumulative-spend",
"why": "CostUSD is what the render phase paid THIS run; CumUSD is what the contour has cost since the book began, replayed checkpoints included at what they cost then. On a FIRST run the two are equal to the cent, so a total built from the wrong one is invisible there — and on a resume it reports money nobody spent, beside a ledger that did not move. The pin's second leg is a resume precisely because the first cannot tell them apart",
"package": "./internal/pipeline/",
"run": "TestTheRunTotalIsThisRunsSpendIncludingTheClassifier",
"battery": true,
"edits": [
{
"file": "internal/pipeline/waverun.go",
"find": "\t\tres.TotalUSD += t.CostUSD + t.ClassifyCostUSD",
"replace": "\t\tres.TotalUSD += t.CumUSD + t.ClassifyCostUSD"
}
]
},
{
"id": "BANKCOMPLETE-the-signing-boundary-alone-publishes-nothing",
"why": "the read-out is refreshed at five boundaries and the signing screen is built from ONE of them — the signature stop. A section published everywhere EXCEPT there is green in every fixture that runs a book to completion, and absent in the only document an owner ever signs against. The pin reads the BYTES on disk at that boundary and checks as_of, so it cannot be satisfied by a later boundary's file",
"package": "./internal/pipeline/",
"run": "TestTheSigningBoundaryPublishesTheCompleteness",
"battery": true,
"edits": [
{
"file": "internal/pipeline/bankexport.go",
"find": "\texp.Consolidation = projectBankConsolidation(r.lastTerminology)",
"replace": "\tif at != \"bank-mining/signature-stop\" {\n\t\texp.Consolidation = projectBankConsolidation(r.lastTerminology)\n\t}"
}
]
},
{
"id": "BANKCOMPLETE-never-asked-is-fed-by-the-wrong-counter",
"why": "NeverAsked is the only counter of the six whose source is ZERO in every end-to-end fixture of this package, so a fold reading Declined into it stays green everywhere: the reflective guard only asks that a field be non-zero somewhere, and the screen's own fixtures build the struct by hand and never cross the seam. Distinct values per source are what make the wiring checkable at all",
"package": "./internal/pipeline/",
"run": "TestEachConsolidationCounterComesFromItsOwnSource",
"battery": true,
"edits": [
{
"file": "internal/pipeline/bankexport.go",
"find": "\t\tNeverAsked: t.BankSettled,",
"replace": "\t\tNeverAsked: t.Declined,"
}
]
},
{
"id": "BANKCOMPLETE-a-pass-that-asked-nothing-reports-bought-batches",
"why": "the already-banked filter can empty the paid set, and then NO batch was ever planned. «Every render batch was bought» is true of zero batches and reads as a report on work that happened — the D39.202 shape, committed by the surface built to remove it. Measured on a live fixture: the screen printed «WHOLE — every render batch was bought (0 consolidated, 0 declined, 0 unanswered)» about a pass that sent nothing and paid nothing",
"package": "./cmd/tmctl/",
"run": "TestTheSigningScreenSaysHowCompleteTheBankIs",
"battery": true,
"edits": [
{
"file": "cmd/tmctl/render.go",
"find": "\tcase c.Complete && c.Consolidated+c.Declined+c.Unanswered == 0:",
"replace": "\tcase false:"
}
]
},
{
"id": "TOCSEAM-the-declared-unreadable-toc-never-leaves-the-log",
"why": "a book that DECLARED a table of contents and whose bytes could not be read falls back to the spine and is sold by characters. The engine has counted that since row 312 and told only its own log, while the far side of the seam reads the manifest — so `structure: delimited` crossed with no way to tell «this book never had one» from «it had one and we could not read it». Dropping the field from the built document restores exactly that silence",
"package": "./internal/pipeline/",
"run": "TestTheManifestSaysADeclaredTableOfContentsCouldNotBeRead",
"battery": true,
"edits": [
{
"file": "internal/pipeline/manifest.go",
"find": "\t\t// Always set, never left nil: absent is reserved for a sidecar written before the field existed.\n\t\tTOCUnreadable: &tocUnreadable,\n",
"replace": ""
}
]
},
{
"id": "TOCSEAM-the-count-is-published-as-a-constant-zero",
"why": "the field can be present, well-typed and always zero — the shape that reads as «measured, nothing wrong» on every book, including the broken ones. The zero case cannot catch it (its answer IS zero), so only a fixture with a declared-and-unreadable table of contents can, and that is why the pin builds a real epub whose navigation document the archive does not carry",
"package": "./internal/pipeline/",
"run": "TestTheManifestSaysADeclaredTableOfContentsCouldNotBeRead",
"battery": true,
"edits": [
{
"file": "internal/pipeline/manifest.go",
"find": "\tm := r.buildManifest(ch, chunks, structure, tocUnreadable, before)",
"replace": "\tm := r.buildManifest(ch, chunks, structure, 0, before)"
}
]
},
{
"id": "BANKCOMPLETE-the-log-calls-a-classifier-cut-a-partial-bank",
"why": "one warning fired by EITHER counter said «this bank is PARTIALLY consolidated» on a run whose render pass was intact and whose classifier alone was cut (measured 08.09: batches_dropped=0, classify_batches_dropped=1, consolidated=29). That is a false alarm about the object an owner signs, and it made the engine contradict its own read-out, which reports the bank's completeness from the render pass alone. A pair of surfaces that argue with each other about one run is worse than either half",
"package": "./internal/pipeline/",
"run": "TestTheLogAndTheReadOutAgreeAboutWhatIsPartial",
"battery": true,
"edits": [
{
"file": "internal/pipeline/terminologist.go",
"find": "\tif res.BatchesDropped > 0 {\n\t\tr.Log.WarnContext(ctx, \"terminology: this bank is PARTIALLY consolidated",
"replace": "\tif res.BatchesDropped > 0 || res.ClassifyBatchesDropped > 0 {\n\t\tr.Log.WarnContext(ctx, \"terminology: this bank is PARTIALLY consolidated"
}
]
}
]

View file

@ -0,0 +1,373 @@
package pipeline
import (
"bytes"
"context"
"encoding/json"
"log/slog"
"os"
"reflect"
"strings"
"testing"
"textmachine/backend/internal/obs"
)
// bankcompleteness_test.go: backlog row 253(б) — the bank an owner signs says whether it IS the whole bank.
//
// The engine has always computed this and always kept it in its own log; the artifact the signing screen
// is built from carried no field for it, so «partially consolidated because a budget ran out» and «this is
// the book's terminology» reached the far side of the seam as the same document. These fixtures assert the
// three states apart, because two of them are easy and the third is the one that gets forgotten: a
// boundary that never measured anything must not read as a bank with nothing missing.
// runToBankExport runs a mining-configured book to completion and returns the read-out it left, plus the
// raw bytes — the bytes matter because an ABSENT key and a zeroed section are the distinction under test
// and they decode identically into a struct.
func runToBankExport(t *testing.T, o miningStopOpts, reply func(string) (string, string)) (BankExport, []byte, *Runner) {
t.Helper()
rec := &reqRec{}
srv := newJSONProvider(rec, reply)
t.Cleanup(srv.Close)
bookPath := setupMiningStopProject(t, srv.URL, o)
r := newRunner(t, bookPath)
t.Cleanup(func() { _ = r.Close() })
if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(r.bankExportPath())
if err != nil {
t.Fatalf("the bank read-out must exist after a run: %v", err)
}
var exp BankExport
if err := json.Unmarshal(raw, &exp); err != nil {
t.Fatalf("the bank read-out must be readable JSON: %v", err)
}
return exp, raw, r
}
// bankReply answers both bank passes and the draft, so a fixture only has to say how it is configured.
func bankReply(body string) (string, string) {
switch {
case isClassifierBody(body):
return "方源\tname\n青茅山\tterm", "stop"
case isTerminologyBody(body):
return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop"
default:
return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop"
}
}
// TestABankCutByABudgetSaysSoInTheReadOut is the state the owner was never shown: the render pass ended
// short, so terms in the unbought batches were never offered to the role at all — and the document he
// signs against now says which, and how many.
//
// Mutation this catches: derive Complete from something other than the render pass's own cut and the
// section reports a whole bank on a run that bought two thirds of one → RED.
func TestABankCutByABudgetSaysSoInTheReadOut(t *testing.T) {
// batch_runes 1 makes three render batches; the budget admits two of them.
exp, _, r := runToBankExport(t, miningStopOpts{terminology: true, batchRunes: 1, budgetUSD: 0.0037}, bankReply)
if r.lastTerminology == nil || r.lastTerminology.BatchesDropped == 0 {
t.Fatalf("premise broken: this fixture must actually cut the render pass, got %+v", r.lastTerminology)
}
c := exp.Consolidation
if c == nil {
t.Fatal("a run that measured the bank must publish how complete it is — the field's absence is " +
"reserved for the boundaries that measured nothing")
}
if c.Complete {
t.Fatalf("the render pass was cut by a budget; the bank is NOT whole: %+v", *c)
}
if c.RenderBatchesDropped != r.lastTerminology.BatchesDropped {
t.Fatalf("the read-out must carry the engine's own count of unbought render batches: %d vs %d",
c.RenderBatchesDropped, r.lastTerminology.BatchesDropped)
}
if c.Consolidated != r.lastTerminology.Consolidated || c.Unanswered != r.lastTerminology.Unanswered {
t.Fatalf("the counters must be the pass's own, not re-derived: read-out %+v vs pass %+v", *c, *r.lastTerminology)
}
}
// TestAWholeBankRaisesNoAlarm is the other half of the same conditional, and it is not optional: a pin
// that only ever asserts the alarm SOUNDS is green on a surface that raises it always.
func TestAWholeBankRaisesNoAlarm(t *testing.T) {
exp, _, r := runToBankExport(t, miningStopOpts{terminology: true}, bankReply)
if r.lastTerminology == nil || r.lastTerminology.BatchesDropped != 0 {
t.Fatalf("premise broken: this fixture must buy every render batch, got %+v", r.lastTerminology)
}
c := exp.Consolidation
if c == nil {
t.Fatal("a run that measured the bank must publish the measurement even when nothing is missing — " +
"silence is the answer reserved for «not measured»")
}
if !c.Complete || c.RenderBatchesDropped != 0 {
t.Fatalf("every render batch was bought; the bank is whole: %+v", *c)
}
if c.Consolidated == 0 {
t.Fatalf("premise broken: nothing was consolidated, so «whole» here would be whole and empty: %+v", *c)
}
}
// TestABoundaryThatMeasuredNothingSaysNothing is the third fixture and the one this class is named after
// (D39.202): the read-out is written at five boundaries and the terminology pass runs at one of them, so a
// zeroed section would answer «consolidated 0, unanswered 0» — «nothing is missing» — about a bank nobody
// looked at. Mining is CONFIGURED here and the delta is non-empty; only the paid role is off.
//
// The assertion is on the BYTES, not on the struct: an absent key and a zero-valued section decode to the
// same Go value, and it is the reader on the far side of the seam who has to tell them apart.
//
// Mutation this catches: publish the section unconditionally (drop the nil check in projectBankConsolidation)
// and this run — which never asked the role anything — starts claiming a complete bank → RED.
func TestABoundaryThatMeasuredNothingSaysNothing(t *testing.T) {
exp, raw, r := runToBankExport(t, miningStopOpts{}, bankReply)
if r.lastTerminology != nil {
t.Fatalf("premise broken: the terminology gate is off in this fixture, got %+v", r.lastTerminology)
}
if len(exp.Terms) == 0 {
t.Fatal("premise broken: the mining wire must still have written a bank, or this asserts nothing")
}
if exp.Consolidation != nil {
t.Fatalf("no pass measured this bank, so the read-out must not describe its completeness: %+v", *exp.Consolidation)
}
if strings.Contains(string(raw), "consolidation") {
t.Fatalf("the key itself must be absent — a present-but-zero section reads as «nothing is missing» "+
"to a consumer that cannot see this test:\n%s", raw)
}
// CONTROL, so the negative above is a measurement and not a spelling: the key IS in the document when
// a pass did measure it. Without this line, renaming the JSON tag would leave the assertion green.
whole, wholeRaw, _ := runToBankExport(t, miningStopOpts{terminology: true}, bankReply)
if whole.Consolidation == nil || !strings.Contains(string(wholeRaw), `"consolidation"`) {
t.Fatalf("control failed: the key must be present when a pass measured the bank:\n%s", wholeRaw)
}
}
// TestAClassifierCutIsNotAnIncompleteBank is the false alarm the naive projection raises, and it is not
// hypothetical: on the live run of 08.09 the render pass was INTACT (batches_dropped=0) while the
// classifier's own budget cut one batch, and the engine's log warning — which fires on either counter —
// said «PARTIALLY consolidated» about a bank that was whole.
//
// The two facts have different remedies (two budgets), and folding them into one bit would send an owner
// to re-buy renderings he already has.
//
// Mutation this catches: make Complete read ClassifyBatchesDropped as well and this whole bank is reported
// partial → RED.
func TestAClassifierCutIsNotAnIncompleteBank(t *testing.T) {
c := projectBankConsolidation(&terminologyResult{
Consolidated: 29, ClassifyBatchesDropped: 1, // the run of 08.09, to the counter
})
if !c.Complete {
t.Fatalf("the render pass ran whole: this bank IS complete, only its term types are unrefined: %+v", *c)
}
if c.ClassifyBatchesDropped != 1 {
t.Fatalf("the classifier's cut must still be carried — it is the other budget's fact, not nothing: %+v", *c)
}
// And the render cut alone is what flips it, which is the same statement from the other side.
if projectBankConsolidation(&terminologyResult{BatchesDropped: 1}).Complete {
t.Fatal("a cut RENDER pass is exactly what makes a bank partial")
}
if projectBankConsolidation(nil) != nil {
t.Fatal("a pass that did not run projects to no section at all")
}
}
// TestEveryConsolidationFieldIsCarried is the reflective guard the proposal section already has, for the
// same reason: a field added here tomorrow and never filled by the fold ships as an absent key, and every
// assertion written against today's fields stays green while the signing screen loses a column.
//
// ⚠ TWO STATES AND NOT ONE, because `Complete` and `RenderBatchesDropped` cannot both be non-zero — the
// first is the negation of the second. A single fixture would therefore have to EXCLUDE one field by name,
// and an exclusion list is the thing that goes stale silently. Requiring each field to be carried in at
// least one of the two states needs no list and covers the struct.
//
// Its limit, named: this catches a field the SECTION grew and the fold ignored. A counter the terminology
// pass grows that ought to be projected and is not cannot be caught mechanically — that is a judgement,
// and it belongs to whoever adds the counter.
func TestEveryConsolidationFieldIsCarried(t *testing.T) {
cut := projectBankConsolidation(&terminologyResult{
BatchesDropped: 1, ClassifyBatchesDropped: 2, Consolidated: 3, Declined: 4, Unanswered: 5, BankSettled: 6,
})
whole := projectBankConsolidation(&terminologyResult{
ClassifyBatchesDropped: 2, Consolidated: 3, Declined: 4, Unanswered: 5, BankSettled: 6,
})
a, b := reflect.ValueOf(*cut), reflect.ValueOf(*whole)
for i := range a.NumField() {
if a.Field(i).IsZero() && b.Field(i).IsZero() {
t.Errorf("field %s is zero in BOTH projections of a pass where every counter was set — the fold "+
"does not carry it", a.Type().Field(i).Name)
}
}
}
// TestTheSignatureStopCarriesTheCompletenessToTheScreen is the end-to-end half of the same row: the CLI
// renders what the stop hands it, so a screen that can print the state proves nothing until the engine
// actually puts the state on the stop. Both live states are asserted on real runs, because the difference
// between them is the whole point and a fixture that can only produce one of them tests a constant.
func TestTheSignatureStopCarriesTheCompletenessToTheScreen(t *testing.T) {
stopWith := func(t *testing.T, o miningStopOpts) *WaveSignatureStop {
t.Helper()
rec := &reqRec{}
srv := newJSONProvider(rec, bankReply)
t.Cleanup(srv.Close)
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL, o))
t.Cleanup(func() { _ = r.Close() })
return runToSignatureStop(t, r)
}
// (1) A cut render pass reaches the screen as a cut render pass.
cut := stopWith(t, miningStopOpts{terminology: true, batchRunes: 1, budgetUSD: 0.0037})
if cut.Consolidation == nil {
t.Fatal("the stop must carry how complete the bank in front of the owner is")
}
if cut.Consolidation.Complete || cut.Consolidation.RenderBatchesDropped == 0 {
t.Fatalf("this run's budget cut the render pass; the stop must say so: %+v", *cut.Consolidation)
}
// (2) And a stop with the paid role OFF carries NO claim about completeness — the state the boundary's
// own name cannot express, since mining is what decides the pause and the terminology gate is a
// separate switch. This is the case a projection keyed on the boundary would get wrong.
unmeasured := stopWith(t, miningStopOpts{})
if unmeasured.Consolidation != nil {
t.Fatalf("no pass measured this bank, so the stop must claim nothing about it: %+v", *unmeasured.Consolidation)
}
}
// TestTheSigningBoundaryPublishesTheCompleteness closes the gap the other fixtures here leave open, and it
// is the boundary the whole row is about.
//
// The three runs above finish, so they exercise `bank-mining/auto-continue` and `run-finished`. The one
// boundary the signing screen is built from is `bank-mining/signature-stop` (mining.go, «the boundary the
// signing screen reads at») — and until this fixture nothing read the ARTIFACT there at all: a projection
// published at every boundary EXCEPT that one would have been green everywhere.
//
// Mutation this catches: skip the section at the signature stop (or publish it only on run-finished) and
// the document the owner signs against goes back to saying nothing about how complete the bank is → RED.
func TestTheSigningBoundaryPublishesTheCompleteness(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, bankReply)
defer srv.Close()
r := newVerifyRunner(t, setupMiningStopProject(t, srv.URL,
miningStopOpts{terminology: true, batchRunes: 1, budgetUSD: 0.0037}))
defer func() { _ = r.Close() }()
stop := runToSignatureStop(t, r)
raw, err := os.ReadFile(r.bankExportPath())
if err != nil {
t.Fatalf("the stop refreshes the read-out the signing screen is built from: %v", err)
}
var exp BankExport
if err := json.Unmarshal(raw, &exp); err != nil {
t.Fatal(err)
}
// The premise that makes this test about the STOP and not about some later boundary: the document on
// disk must be the one the stop wrote. Without it a passing assertion below could be describing
// `run-finished`, which a stopped run never reaches anyway — and that is exactly the kind of accident
// that leaves a boundary untested while the file looks right.
if exp.AsOf != "bank-mining/signature-stop" {
t.Fatalf("premise broken: the artifact must be the stop's own projection, got as_of=%q", exp.AsOf)
}
if exp.Consolidation == nil {
t.Fatal("the document the owner signs against must say how complete the bank in it is — this is the " +
"one boundary opened FOR that decision")
}
if exp.Consolidation.Complete {
t.Fatalf("this run's budget cut the render pass; the signed document must not call the bank whole: %+v",
*exp.Consolidation)
}
// The screen and the artifact are ONE projection: a reader of either must not be able to get a
// different answer from the other about the same stop.
if stop.Consolidation == nil || *stop.Consolidation != *exp.Consolidation {
t.Fatalf("the screen and the artifact must carry the same measurement: screen %+v, artifact %+v",
stop.Consolidation, *exp.Consolidation)
}
}
// TestEachConsolidationCounterComesFromItsOwnSource pins the WIRING, which the reflective guard above
// cannot: with every source non-zero, a fold that read the wrong counter into a field is still non-zero
// everywhere and stays green. Every number here is DIFFERENT, so any swap moves a value that is compared.
//
// It matters most for NeverAsked, whose source is the only one that is zero in every end-to-end fixture in
// this package — so nothing else in the tree would have noticed it reading Declined instead.
func TestEachConsolidationCounterComesFromItsOwnSource(t *testing.T) {
got := projectBankConsolidation(&terminologyResult{
BatchesDropped: 11, ClassifyBatchesDropped: 22, Consolidated: 33,
Declined: 44, Unanswered: 55, BankSettled: 66,
})
want := &BankConsolidation{
Complete: false, RenderBatchesDropped: 11, ClassifyBatchesDropped: 22,
Consolidated: 33, Declined: 44, Unanswered: 55, NeverAsked: 66,
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("every field must come from its OWN counter — and a field added since this list was "+
"written has to be added to it, deliberately, by whoever adds the counter:\n got %+v\nwant %+v",
*got, *want)
}
}
// TestTheLogAndTheReadOutAgreeAboutWhatIsPartial closes the last place the engine contradicted itself
// about the object an owner signs.
//
// One warning fired by EITHER counter said «this bank is PARTIALLY consolidated» on a run whose render
// pass was intact and whose classifier alone was cut — measured 08.09 (`batches_dropped=0`,
// `classify_batches_dropped=1`, `consolidated=29`). The read-out this pack added reports the bank's
// completeness from the render pass alone, so the same run shipped two opposite verdicts: «PARTIALLY
// consolidated» in the log and «WHOLE» on the screen. A pair that argues with itself is worse than either
// half, so the log now says the same thing the projection does.
//
// ⚠ BOTH HALVES OF BOTH MESSAGES, which is what a conditional message costs: each is asserted where it
// MUST sound and where it MUST stay silent. A pin that only checks the silence is vacuous exactly where
// the sentence is false.
func TestTheLogAndTheReadOutAgreeAboutWhatIsPartial(t *testing.T) {
const (
bankPartial = "this bank is PARTIALLY consolidated"
typesCutShort = "the TYPE classifier ended short"
)
run := func(t *testing.T, o miningStopOpts) (string, *terminologyResult) {
t.Helper()
rec := &reqRec{}
srv := newJSONProvider(rec, bankReply)
t.Cleanup(srv.Close)
r := newRunner(t, setupMiningStopProject(t, srv.URL, o))
t.Cleanup(func() { _ = r.Close() })
var logBuf bytes.Buffer
r.Log = slog.New(slog.NewTextHandler(&logBuf, &slog.HandlerOptions{Level: slog.LevelWarn}))
if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil {
t.Fatal(err)
}
return logBuf.String(), r.lastTerminology
}
// (1) The RENDER pass was cut: the bank IS partial, and the log has to say so.
out, res := run(t, miningStopOpts{terminology: true, batchRunes: 1, budgetUSD: 0.0037})
if res == nil || res.BatchesDropped == 0 {
t.Fatalf("premise broken: this fixture must cut the render pass, got %+v", res)
}
if !strings.Contains(out, bankPartial) {
t.Errorf("a cut RENDER pass is exactly what makes a bank partial, and the log must say it:\n%s", out)
}
// (2) Only the CLASSIFIER was cut: the bank is whole, and the log must NOT call it partial — this is
// the false alarm, and it is the half a silence-only pin would have missed.
out2, res2 := run(t, miningStopOpts{terminology: true, classify: true, batchRunes: 1, classifyBudgetUSD: 0.0037})
if res2 == nil || res2.ClassifyBatchesDropped == 0 {
t.Fatalf("premise broken: this fixture must cut the CLASSIFY pass, got %+v", res2)
}
if res2.BatchesDropped != 0 {
t.Fatalf("premise broken: the render pass must be INTACT here, or the two facts are not separated: %+v", res2)
}
if strings.Contains(out2, bankPartial) {
t.Errorf("the render pass ran whole: calling this bank partially consolidated is the false alarm "+
"this fix removes, and it disagrees with the read-out of the same run:\n%s", out2)
}
if !strings.Contains(out2, typesCutShort) {
t.Errorf("the classifier's own cut is still a fact an operator acts on — on the OTHER budget:\n%s", out2)
}
// (3) Nothing was cut: neither sentence appears. Without this the two above are satisfied by a log
// that prints both messages always.
out3, res3 := run(t, miningStopOpts{terminology: true, classify: true})
if res3 == nil || res3.BatchesDropped != 0 || res3.ClassifyBatchesDropped != 0 {
t.Fatalf("premise broken: this fixture must buy every batch of both passes, got %+v", res3)
}
if strings.Contains(out3, bankPartial) || strings.Contains(out3, typesCutShort) {
t.Errorf("nothing was cut in this run; neither warning may fire:\n%s", out3)
}
}

View file

@ -62,6 +62,61 @@ type BankExport struct {
// Empty at every boundary that is not a stop. Order is the stop's own RANKING, not a sort: which row
// to read first is the information here, and the bank's diffable byte-stability belongs to Terms.
Proposed []BankExportProposal `json:"proposed,omitempty"`
// Consolidation is how COMPLETE this bank is — and ABSENT, never zeroed, when nothing measured it.
// Backlog row 253(б): the engine has always known that a budget cut the pass and said so in its own
// log, while the artifact the signing screen is built from carried no field for it, so an owner signed
// a bank the engine itself calls partial. A pointer because the read-out is written at five boundaries
// and the pass runs at one: a zeroed section would answer «consolidated 0, unanswered 0» — which reads
// as «nothing is missing» — at the four boundaries that never asked.
Consolidation *BankConsolidation `json:"consolidation,omitempty"`
}
// BankConsolidation is what the paid terminology pass MANAGED, in the fields a signing decision needs. It
// is a projection of counters the pass already produced; nothing here re-derives completeness by a second
// route, because two mechanisms answering one question is how they come to disagree.
//
// ⛔ THREE FACTS THAT LOOK ALIKE AND ARE NOT, which is why this is six fields and not one number:
// - the RENDER pass was cut → terms have no consolidated rendering → the BANK is partial;
// - the CLASSIFY pass was cut → the bank is whole and the term TYPES are unrefined. A different budget,
// a different remedy, and reading it as an incomplete bank is a false alarm. On the run of 08.09 the
// engine's own warning fired on exactly this: the render pass was intact and it still said «PARTIALLY
// consolidated»;
// - candidates the role was NEVER ASKED about because the bank already renders them — a saving, not a gap.
type BankConsolidation struct {
// Complete is the BANK's completeness and nothing else: every render batch the pass planned was bought.
// It is published rather than left to the reader to derive, because deriving it means re-implementing
// engine law on the far side of the seam — the thing the inbound law forbids (D39.156 п.6).
Complete bool `json:"complete"`
// RenderBatchesDropped is what makes Complete false. ClassifyBatchesDropped is the OTHER pass's cut, on
// its OWN budget: two fields and never one sum, because an operator raises `budget_usd` or
// `classify_budget_usd`, never «the» budget.
RenderBatchesDropped int `json:"render_batches_dropped"`
ClassifyBatchesDropped int `json:"classify_batches_dropped"`
// Consolidated came back with a rendering; Declined the role explicitly could not render.
Consolidated int `json:"consolidated"`
Declined int `json:"declined"`
// Unanswered is the role's silence AND the budget's cut TOGETHER — the engine's counter conflates them
// and says so in the warning beside it: a term in a dropped batch was never offered, and lands here
// looking exactly like one the role saw and skipped. It means «the role stayed silent» only where
// Complete is true; where it is false, part of this number is the money and not the model.
Unanswered int `json:"unanswered"`
// NeverAsked is the opposite of a gap and must not be read as one: these candidates never reached the
// paid role because the bank already renders the surface and every draft agreed with it.
NeverAsked int `json:"never_asked"`
}
// projectBankConsolidation turns the pass's counters into the section. NIL IN, NIL OUT, and that is the
// whole distinction the section exists to publish: a pass that did not run has not found the bank complete.
func projectBankConsolidation(t *terminologyResult) *BankConsolidation {
if t == nil {
return nil
}
return &BankConsolidation{
Complete: t.BatchesDropped == 0,
RenderBatchesDropped: t.BatchesDropped, ClassifyBatchesDropped: t.ClassifyBatchesDropped,
Consolidated: t.Consolidated, Declined: t.Declined, Unanswered: t.Unanswered,
NeverAsked: t.BankSettled,
}
}
// BankExportProposal is one row of a stop's verification table in the fields a signing decision needs.
@ -212,6 +267,9 @@ func (r *Runner) exportBank(ctx context.Context, at string) {
// else, and a stopped run returns before every later boundary, so this is non-empty exactly at a
// signature stop.
exp.Proposed = projectBankProposals(r.lastBankStopRows)
// The completeness of what is being signed (row 253б). r.lastTerminology is nil until the paid pass has
// run, so the section is absent at the boundaries that never measured it rather than zero there.
exp.Consolidation = projectBankConsolidation(r.lastTerminology)
body, err := json.MarshalIndent(exp, "", " ")
if err != nil {
r.Log.WarnContext(ctx, "bank export: could not marshal the bank; the export artifact was NOT refreshed and may be stale",

View file

@ -86,7 +86,7 @@ func TestBankStopRowCarriesTheWinningVariantsSignals(t *testing.T) {
if len(c.Variants[0].Signals) == 0 {
t.Fatalf("test premise broken: the winner must have fired at least one factor: %+v", c.Variants)
}
rows := bankStopRows([]terminology.Candidate{c}, map[string]string{"元海": "море истинной ци"}, terminologyResult{})
rows := bankStopRows([]terminology.Candidate{c}, map[string]string{"元海": "море истинной ци"}, nil)
if len(rows[0].Signals) == 0 {
t.Fatalf("the winning variant's audit trail must reach the row: %+v", rows[0])
}
@ -550,7 +550,7 @@ func TestConsolidatedRowsCarryTheWindowAndTheAliases(t *testing.T) {
// plausibly match it — candidate key, raw surface, firing key (an alias here) and the bank row's own
// source — are all different.
res := terminologyResult{BankHoldRows: got}
sheet := bankStopRows(cands, map[string]string{"赵甲": "Чжао Цзя"}, res)
sheet := bankStopRows(cands, map[string]string{"赵甲": "Чжао Цзя"}, &res)
if len(sheet) == 0 || sheet[0].Src != "趙甲" {
t.Fatalf("premise broken: the sheet row must be the raw-surface candidate: %+v", sheet)
}

View file

@ -0,0 +1,279 @@
package pipeline
import (
"context"
"testing"
"textmachine/backend/internal/obs"
"textmachine/backend/internal/store"
)
// bankmoneyledger_test.go: the two figures an operator reads about the BANK's money, each checked against
// the ledger rather than against the code that produces it (backlog rows 358 and 355).
//
// Both defects were found on the same live run and both are the same shape: the engine settled the money
// correctly and then told the operator a number that the ledger does not contain. So the assertions here
// take the report's figure and the ledger's rows on ONE fixture and state their relation — an equality for
// the run total, a class membership for the decomposition. Reading the code instead would re-derive the
// arithmetic being tested.
// TestTheRunTotalIsThisRunsSpendIncludingTheClassifier is row 358, and the classifier is ON for a reason
// that is the whole point of the fixture: with it off, ClassifyCostUSD is structurally zero and the
// missing addend is invisible — a green test about a figure nobody added. TestTerminologistSpendIsInTheRunTotal
// is exactly that fixture (`miningStopOpts{terminology: true}`, no classify) and it stays green either way.
//
// ⛔ THE INVARIANT IS «THIS RUN'S FRESH SPEND», NOT «THE LEDGER», and the second leg is what says so. On a
// first run the two coincide, because every call was paid now — which is what makes the equality checkable
// at all. On a RESUME they part: the ledger still holds the whole book while the run paid nothing, so a
// total built from the terminology's CUMULATIVE figure (CumUSD, which counts replayed checkpoints at what
// they cost then) would report money this run did not spend. That confusion is invisible on a first run,
// where CostUSD and CumUSD are equal to the cent.
//
// Mutations this catches: drop the ClassifyCostUSD addend → leg 1 falls short by the classify phase's
// spend; read CumUSD instead of CostUSD → leg 2 reports a paid run where nothing was bought.
func TestTheRunTotalIsThisRunsSpendIncludingTheClassifier(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, func(body string) (string, string) {
if isClassifierBody(body) {
return "方源\tname\n青茅山\tterm", "stop"
}
if isTerminologyBody(body) {
return "方源\tФан Юань\n青茅山\tгора Цинмао", "stop"
}
return "Фан Юань пришёл к горе Цинмао.\n" + bankBlockForMining, "stop"
})
defer srv.Close()
// batch_runes 1 splits both passes into three batches; the render budget then admits two of them while
// the classify budget (1.0) admits all three. That asymmetry is deliberate and it is the only lever the
// fixture has: the fake model bills a FIXED price per call, so two passes making the same number of
// calls cost the same to the cent and a total built from the wrong addend would pass. The premise below
// asserts the asymmetry rather than trusting it to survive a change in the estimate arithmetic.
bookPath := setupMiningStopProject(t, srv.URL, miningStopOpts{
terminology: true, classify: true, batchRunes: 1, budgetUSD: 0.0037,
})
// --- leg 1: the first run pays for everything it does, so its total IS the whole ledger ---
r1 := newRunner(t, bookPath) // AUTO mode: the run has to finish for there to be a total at all
res1, err := r1.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}))
if err != nil {
t.Fatal(err)
}
// The premise, stated as an assertion: BOTH bank passes must have paid, and their two figures must
// DIFFER. A fixture where the render and the classify cost the same amount would pass whichever addend
// the code used (D39.208 п.5), and a fixture where either is zero tests nothing at all.
if r1.lastTerminology == nil {
t.Fatal("premise broken: the terminology pass did not run, so this fixture measures nothing")
}
render, classify := r1.lastTerminology.CostUSD, r1.lastTerminology.ClassifyCostUSD
if render <= 0 || classify <= 0 || render == classify {
t.Fatalf("degenerate fixture: the render and classify phases must both have paid, and paid "+
"DIFFERENT amounts, or the sum cannot say which addend it is made of: render=%v classify=%v",
render, classify)
}
committed1, _, err := r1.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
if committed1 <= 0 {
t.Fatalf("premise broken: the ledger holds no money for this run (%v)", committed1)
}
if d := res1.TotalUSD - committed1; d > 1e-9 || d < -1e-9 {
t.Fatalf("the run total an operator reads must be the ledger's own sum on a run that paid for "+
"everything it did: TOTAL=$%.9f ledger=$%.9f (short by $%.9f; the classify phase paid $%.9f)",
res1.TotalUSD, committed1, committed1-res1.TotalUSD, classify)
}
_ = r1.Close()
// --- leg 2: the resume pays nothing, and the total has to say nothing ---
r2 := newRunner(t, bookPath)
defer func() { _ = r2.Close() }()
res2, err := r2.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()}))
if err != nil {
t.Fatal(err)
}
if r2.lastTerminology == nil {
t.Fatal("premise broken: the resumed run must still run the pass (replaying it), or leg 2 is vacuous")
}
// The premise that makes the leg non-degenerate: this run's spend and the contour's cumulative spend
// must now be DIFFERENT numbers. On leg 1 they are equal and the distinction cannot be tested.
fresh, cumulative := r2.lastTerminology.CostUSD, r2.lastTerminology.CumUSD
if fresh != 0 || cumulative <= 0 {
t.Fatalf("premise broken: a resume must replay the contour for $0 while the cumulative figure keeps "+
"what it cost then: fresh=$%.9f cumulative=$%.9f", fresh, cumulative)
}
committed2, _, err := r2.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
if d := res2.TotalUSD - (committed2 - committed1); d > 1e-9 || d < -1e-9 {
t.Fatalf("the total reports what THIS run spent, not what the book has cost: TOTAL=$%.9f, this run "+
"added $%.9f to the ledger (%.9f → %.9f); the contour's cumulative figure is $%.9f and is NOT it",
res2.TotalUSD, committed2-committed1, committed1, committed2, cumulative)
}
}
// TestTheLedgerSaysTheClassifierBoughtTheBank is row 355 against the store rather than against a hand-built
// slice: the two bank roles are planted through the REAL money path — reserve → settle+checkpoint — at the
// position they actually collide on, and the decomposition is then read back through the same query the
// report uses.
//
// The collision is not hypothetical arithmetic. The bank roles are checkpointed under one synthetic stage
// at chapter 0 and number their batches in chunk_idx, so the classifier's batch 0 and the terminologist's
// batch 0 are the same (chapter, chunk, stage) triple. Keyed on that triple alone, the earlier of the two —
// the classifier, and on the live run the LARGEST of the three rows — is filed as money that bought
// nothing.
//
// Mutation this catches: stop reading the role in posOf and the two roles share a position again → the
// classifier lands in `superseded` → RED naming the amount.
func TestTheLedgerSaysTheClassifierBoughtTheBank(t *testing.T) {
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
bookPath := volumeBook(t, srv.URL, 2)
ctx := obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})
r := newRunner(t, bookPath)
defer r.Close()
if _, err := r.TranslateBook(ctx); err != nil {
t.Fatal(err)
}
if err := r.Store.UpsertSnapshot("snap-terminology", "brief", `{"k":1}`); err != nil {
t.Fatal(err) // jobs reference snapshots; the row has to exist before a job can point at one
}
job, err := r.Store.EnsureJob("test-book", 0, terminologyStageName, "snap-terminology")
if err != nil {
t.Fatal(err)
}
// The live run's own shape: the classifier's batch 0 first and dearest, then the terminologist's
// batches 0 and 1. Three DISTINCT amounts, so no assertion below can be satisfied by the wrong row.
const classifyUSD, renderUSD, renderNextUSD = 0.009013, 0.002775, 0.001820
plant := func(hash, role string, chunkIdx int, usd float64) {
t.Helper()
res, verdict, err := r.Store.Reserve("test-book", usd, store.Ceilings{BookUSD: 100, DayUSD: 100})
if err != nil || verdict != store.ReserveOK {
t.Fatalf("reserve for %s: %v %v", hash, verdict, err)
}
if err := r.Store.SettleWithCheckpoint(res, usd, store.Checkpoint{
RequestHash: hash, JobID: job.ID, ChunkIdx: chunkIdx,
Stage: terminologyStageName, Role: role,
ModelRequested: "fake-model", ModelActual: "fake-model",
ResponseText: "терм\tterm", UsageJSON: "{}", CostUSD: usd, FinishReason: "stop",
}, nil); err != nil {
t.Fatalf("settle for %s: %v", hash, err)
}
}
plant("planted-classify-batch-0", roleClassifier, 0, classifyUSD)
plant("planted-render-batch-0", roleTerminologist, 0, renderUSD)
plant("planted-render-batch-1", roleTerminologist, 1, renderNextUSD)
usage, err := r.Store.CheckpointUsageForBook("test-book")
if err != nil {
t.Fatal(err)
}
// The premise the whole fix rests on: the ledger DOES carry the role, and the two planted rows really
// do share a (chapter, chunk, stage) triple. Without this, a query that dropped the column would leave
// every assertion below passing for the wrong reason.
var collide int
for _, u := range usage {
if u.Chapter == 0 && u.ChunkIdx == 0 && u.Stage == terminologyStageName {
collide++
if u.Role == "" {
t.Fatalf("the ledger must carry the role of a bank call, got %+v", u)
}
}
}
if collide != 2 {
t.Fatalf("premise broken: exactly two planted rows share the batch-0 triple, got %d", collide)
}
statuses, err := r.Store.ChunkStatusesForBook("test-book")
if err != nil {
t.Fatal(err)
}
got := paidTail(usage, statuses)
// (1) All three bank rows bought the bank. None of them replaced another: they are three purchases of
// three different things, and only one of the three roles was even asked twice.
if d := got.BankUSD - (classifyUSD + renderUSD + renderNextUSD); d > 1e-9 || d < -1e-9 || got.BankCalls != 3 {
t.Fatalf("every bank call here stands and bought the book's terminology: bank=$%.9f calls=%d, "+
"want $%.9f over 3 calls", got.BankUSD, got.BankCalls, classifyUSD+renderUSD+renderNextUSD)
}
// (2) And specifically NOT this: the classifier reported as money that bought nothing. On the live run
// it was the largest of the three rows, printed under a total that excluded it.
if got.SupersededUSD != 0 || got.SupersededCalls != 0 {
t.Fatalf("nothing here was replaced — the classifier and the terminologist bought different things "+
"at one synthetic address: superseded=$%.9f over %d call(s)", got.SupersededUSD, got.SupersededCalls)
}
if got.LostUSD() != 0 {
t.Fatalf("this book lost nothing: %+v", got)
}
// (3) The decomposition still sums to the ledger it claims to decompose — the fix moves rows BETWEEN
// classes and must not invent or drop a cent.
committed, _, err := r.Store.SpentUSD("test-book")
if err != nil {
t.Fatal(err)
}
if d := got.TotalUSD - committed; d > 1e-9 || d < -1e-9 {
t.Fatalf("the decomposition must sum to the committed ledger: $%.9f vs $%.9f", got.TotalUSD, committed)
}
}
// TestTwoBankRolesInOneBatchAreTwoPositions is the arithmetic half, and the fixture the package did not
// have: every existing paidtail fixture leaves Role empty, so the collision this fix removes could not
// appear in one of them and the battery was green on the defect (D39.208 п.5).
//
// It also states the OTHER half of the rule, which a role-in-every-key change would have broken silently:
// two calls of the SAME role at one position still replace each other. That is what makes a re-bought
// contour a loss (backlog row 233) rather than an amnesty.
func TestTwoBankRolesInOneBatchAreTwoPositions(t *testing.T) {
usage := []store.CheckpointUsage{
{Chapter: 0, ChunkIdx: 0, Stage: terminologyStageName, Role: roleClassifier, CostUSD: 0.009},
{Chapter: 0, ChunkIdx: 0, Stage: terminologyStageName, Role: roleTerminologist, CostUSD: 0.002},
{Chapter: 0, ChunkIdx: 0, Stage: terminologyStageName, Role: roleTerminologist, CostUSD: 0.003},
}
got := paidTail(usage, nil)
// The classifier is untouched by the terminologist's re-purchase: different role, different position.
// The terminologist's own first batch IS replaced by its second.
if d := got.BankUSD - (0.009 + 0.003); d > 1e-9 || d < -1e-9 || got.BankCalls != 2 {
t.Fatalf("the classifier's call and the terminologist's LAST call both stand: %+v", got)
}
if d := got.SupersededUSD - 0.002; d > 1e-9 || d < -1e-9 || got.SupersededCalls != 1 {
t.Fatalf("only the terminologist's re-bought batch was replaced: %+v", got)
}
// The two positions must also be two NAMES: an operator sent to «book/batch0/terminology» cannot tell
// which of the two roles lost the money.
if got.WorstPosition != "book/batch0/terminology/terminologist" {
t.Fatalf("the lost position must name the role that lost it, got %q", got.WorstPosition)
}
}
// TestTheRoleSplitDoesNotMoveARepairsMoney is the BOUNDARY of the role split, and it guards the change
// this pack did NOT make.
//
// A repair call carries its stage's real name and its own synthetic role (repair.go), so it lands on the
// same (chapter, chunk, stage) position as the stage call it repairs — and, being later, it replaces it.
// Folding the role into every position would separate the two and quietly move the repaired call's money
// out of `superseded`: a second money change, on a different backlog row, landed under this pack's report.
//
// ⚠ What this pin asserts is that the classification did not MOVE, not that it is the right one. Whether a
// stage call whose output a repair then fixed really «bought nothing» is a live question and it is left
// open in this pack's report; the answer belongs to whoever takes that row, and this pin is what will make
// their change visible instead of silent.
func TestTheRoleSplitDoesNotMoveARepairsMoney(t *testing.T) {
usage := []store.CheckpointUsage{
{Chapter: 1, ChunkIdx: 0, Stage: "edit", Role: "editor", CostUSD: 0.020},
{Chapter: 1, ChunkIdx: 0, Stage: "edit", Role: roleRepair, CostUSD: 0.010},
}
statuses := []store.ChunkStatus{{Chapter: 1, ChunkIdx: 0, Stage: "edit", FinalHash: "h"}}
got := paidTail(usage, statuses)
if d := got.ShippedUSD - 0.010; d > 1e-9 || d < -1e-9 || got.ShippedCalls != 1 {
t.Fatalf("the repair is the call that stands at this position, and its position shipped: %+v", got)
}
if d := got.SupersededUSD - 0.020; d > 1e-9 || d < -1e-9 || got.SupersededCalls != 1 {
t.Fatalf("the two roles share a TEXT position and the later one replaces the earlier — this pack "+
"separates the BANK roles only: %+v", got)
}
// The two roles must actually differ, or the fixture would pass with the role read everywhere.
if usage[0].Role == usage[1].Role {
t.Fatal("degenerate fixture: the two calls must carry different roles")
}
}

View file

@ -191,7 +191,7 @@ func (r *Runner) translateBook(ctx context.Context) (*BookResult, error) {
// new source against itself and answer «nothing moved» — silently disabling the re-payment consent
// gate for an in-place source edit, which is the one path where money is authorised (backlog row 238).
r.noteSourceVintage()
r.persistManifest(ctx, srcBefore, denseFrom(doc, keptIdx), chunks, doc.Structure)
r.persistManifest(ctx, srcBefore, denseFrom(doc, keptIdx), chunks, doc.Structure, doc.TOCUnreadable)
// the precompute pass: the banknote parser's source index (backlog 19). Built here, in the composite
// root, because the rule "a bank line must name something the book contains" needs the whole chunk
// manifest, and building it anywhere else would let a call path exist without it.

View file

@ -307,7 +307,7 @@ func TestManifestChunksReproduceTheCut(t *testing.T) {
t.Fatal(err)
}
_, chapterTexts, _ := chunk.SplitChunksWithChapters(mustIngestChapters(t, r), r.segBudget(), r.chapterRule(), r.sentenceAbbrevs())
m := r.buildManifest(denseChapters{texts: chapterTexts}, full, chunk.StructureDetected, sourceFingerprint{SHA: "sha-fixture", Bytes: 42})
m := r.buildManifest(denseChapters{texts: chapterTexts}, full, chunk.StructureDetected, 0, sourceFingerprint{SHA: "sha-fixture", Bytes: 42})
got := m.chunks()
if len(got) != len(full) {
t.Fatalf("manifest reconstructs %d chunks, the cut has %d", len(got), len(full))
@ -819,7 +819,7 @@ func TestManifestIsNotWrittenWhenTheSourceMovesUnderTheRead(t *testing.T) {
if err := os.WriteFile(r.Book.SourceFile, []byte("ПЕРВАЯ\fВТОРАЯ\fТРЕТЬЯ"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := r.writeManifest(before, denseChapters{texts: chapterTexts}, chunks, chunk.StructureNone); err == nil {
if _, err := r.writeManifest(before, denseChapters{texts: chapterTexts}, chunks, chunk.StructureNone, 0); err == nil {
t.Fatal("a manifest cut from bytes that are no longer on disk must NOT be written — it would validate forever against the new hash")
}
if _, serr := os.Stat(r.manifestPath()); serr == nil {

View file

@ -67,6 +67,24 @@ type BookManifest struct {
// offers an order phrased in chapters: an order «through chapter 12» against a detected cut is an
// order against a guess, and a reader has to be told that in words rather than discover it.
Structure string `json:"structure"`
// TOCUnreadable is how many tables of contents the book DECLARED and this reader could not read at all
// — the other half of the sentence Structure starts. `structure: delimited` on a book that declared a
// nav or an NCX means the declared path was TRIED AND FAILED, and until this field the difference
// between that and a book that never had one existed only in the engine's own log while the far side
// read the sidecar (backlog row 312, Д-3). A buyer told «delimited» about a book with a broken table of
// contents is being sold a cut by characters and not told why.
//
// ⚠ ADDITIVE, AND THE DOCUMENT VERSION DELIBERATELY DOES NOT MOVE FOR IT — the same reasoning Price
// and Artifacts carry above: a bump makes loadManifest discard every stored sidecar and re-cut every
// book, which is a price paid for nothing here.
//
// ⛔ A POINTER, AND THAT IS THE WHOLE COST OF BEING ADDITIVE. A sidecar written by an older build
// passes the version, the key and selfConsistent, comes back as «current» and carries no such field —
// so ABSENT means «this document cannot answer», a THIRD state and not a shade of zero. A plain int
// would render that as «none declared, none unreadable», which is the exact class that already bit
// `price` here once (readModelPrice, found by acceptance V2-3). Every build that writes a manifest
// sets it, so absent can mean nothing else.
TOCUnreadable *int `json:"toc_unreadable,omitempty"`
Chapters []ManifestChapter `json:"chapters"`
ChaptersTotal int `json:"chapters_total"`
@ -403,7 +421,7 @@ func denseFrom(doc *chunk.Document, keptIdx []int) denseChapters {
// buildManifest projects a fresh split into the document. ch holds the chapters that took a number and
// their source titles, index-aligned to chapter
// numbers (chunk.SplitChunksWithChapters), chunks is the split itself. Pure and deterministic.
func (r *Runner) buildManifest(ch denseChapters, chunks []chunk.Chunk, structure string, src sourceFingerprint) *BookManifest {
func (r *Runner) buildManifest(ch denseChapters, chunks []chunk.Chunk, structure string, tocUnreadable int, src sourceFingerprint) *BookManifest {
m := &BookManifest{
Version: manifestVersion, BookID: r.Book.BookID,
Key: r.manifestKey(src),
@ -411,6 +429,8 @@ func (r *Runner) buildManifest(ch denseChapters, chunks []chunk.Chunk, structure
SourceSHA256: src.SHA, SourceBytes: src.Bytes,
SourceLang: r.Book.SourceLang, TargetLang: r.Book.TargetLang, Encoding: r.Book.Encoding,
ChunksTotal: len(chunks), Artifacts: r.artifacts(), Structure: structure,
// Always set, never left nil: absent is reserved for a sidecar written before the field existed.
TOCUnreadable: &tocUnreadable,
}
// The price projection is built from the SAME unit decomposition the ids below are built from, in the
// same pass, so a unit and its price can never describe different text (priceprojection.go).
@ -494,11 +514,11 @@ func (r *Runner) buildManifest(ch denseChapters, chunks []chunk.Chunk, structure
// accelerator and a reader's tree, not an input to anything the run decides — failing a paid run over it
// would trade money for an artifact the next run rewrites. A reader that finds no manifest (or a stale
// one) falls back to the full re-chunk, so the failure degrades to the pre-existing behaviour.
func (r *Runner) persistManifest(ctx context.Context, before *sourceFingerprint, ch denseChapters, chunks []chunk.Chunk, structure string) {
func (r *Runner) persistManifest(ctx context.Context, before *sourceFingerprint, ch denseChapters, chunks []chunk.Chunk, structure string, tocUnreadable int) {
if before == nil {
return // sourceFingerprintBeforeIngest already said why
}
if _, err := r.writeManifest(*before, ch, chunks, structure); err != nil {
if _, err := r.writeManifest(*before, ch, chunks, structure, tocUnreadable); err != nil {
r.Log.WarnContext(ctx, "manifest: could not persist the chapter/chunk manifest; read paths fall back to re-chunking the source and the chapter tree may be stale",
"path", r.manifestPath(), "err", err)
}
@ -515,7 +535,7 @@ func (r *Runner) persistManifest(ctx context.Context, before *sourceFingerprint,
// document that describes the OLD cut, matches its own key forever, and can never be detected as stale.
// A source that moved under the read is therefore not written at all — the next write path produces a
// consistent one, and until then readers take the full re-chunk.
func (r *Runner) writeManifest(before sourceFingerprint, ch denseChapters, chunks []chunk.Chunk, structure string) (*BookManifest, error) {
func (r *Runner) writeManifest(before sourceFingerprint, ch denseChapters, chunks []chunk.Chunk, structure string, tocUnreadable int) (*BookManifest, error) {
after, err := sourceSHA256(r.Book.SourceFile)
if err != nil {
return nil, fmt.Errorf("pipeline: re-hash source %s for the manifest: %w", r.Book.SourceFile, err)
@ -524,7 +544,7 @@ func (r *Runner) writeManifest(before sourceFingerprint, ch denseChapters, chunk
return nil, fmt.Errorf("pipeline: the source %s changed while it was being read (%.12s/%d bytes → %.12s/%d bytes): the manifest is NOT written, because a structure cut from the old bytes stamped with the new hash would validate forever",
r.Book.SourceFile, before.SHA, before.Bytes, after.SHA, after.Bytes)
}
m := r.buildManifest(ch, chunks, structure, before)
m := r.buildManifest(ch, chunks, structure, tocUnreadable, before)
body, err := json.MarshalIndent(m, "", " ")
if err != nil {
return nil, fmt.Errorf("pipeline: marshal the manifest: %w", err)
@ -554,7 +574,7 @@ func (r *Runner) BuildAndPersistManifest() (*BookManifest, error) {
if len(chunks) == 0 {
return nil, sourceHasNoContent(fmt.Errorf("pipeline: source file %s produced no chunks after normalization", r.Book.SourceFile))
}
return r.writeManifest(before, denseFrom(doc, keptIdx), chunks, doc.Structure)
return r.writeManifest(before, denseFrom(doc, keptIdx), chunks, doc.Structure, doc.TOCUnreadable)
}
// ManifestPath exposes where the artifact lives, for a CLI that has to tell the operator.

View file

@ -0,0 +1,142 @@
package pipeline
import (
"context"
"encoding/json"
"os"
"path/filepath"
"testing"
"textmachine/backend/internal/chunk"
"textmachine/backend/internal/chunk/chunktest"
"textmachine/backend/internal/obs"
)
// manifesttoc_test.go: backlog row 312, Д-3 — the alarm the engine already raises reaches the reader who
// decides on it, instead of living in the engine's own log.
//
// A book that DECLARES a table of contents and whose bytes cannot be read falls back to the spine and is
// sold by characters. The count existed (chunk.Document.TOCUnreadable, pinned in the chunk package) and
// went to r.Log.Warn, while the far side of the seam reads the manifest — so `structure: delimited` was
// published with no way to tell «this book never had a table of contents» from «it had one and we could
// not read it».
// manifestOf runs a book and returns the manifest it left, plus the raw bytes: an ABSENT key and a
// present zero are the distinction under test, and they decode to different Go values only because the
// field is a pointer — the bytes are what a reader on the other side actually gets.
func manifestOf(t *testing.T, bookPath string) (BookManifest, []byte) {
t.Helper()
r := newRunner(t, bookPath)
t.Cleanup(func() { _ = r.Close() })
if _, err := r.TranslateBook(obs.WithReqInfo(context.Background(), obs.ReqInfo{TraceID: obs.NewTraceID()})); err != nil {
t.Fatal(err)
}
raw, err := os.ReadFile(r.manifestPath())
if err != nil {
t.Fatalf("a run must leave a manifest: %v", err)
}
var m BookManifest
if err := json.Unmarshal(raw, &m); err != nil {
t.Fatal(err)
}
return m, raw
}
// epubBook builds the fixture project and then REPLACES its source with the epub the case needs — the
// shared helper writes a plain one and has no way to declare a table of contents that is missing.
func epubBook(t *testing.T, providerURL string, book chunktest.EPUB) string {
t.Helper()
ids := make([]string, 0, len(book.Chapters))
for _, c := range book.Chapters {
ids = append(ids, c.ID)
}
bookPath := setupProjectOpts(t, providerURL, projectOpts{epub: book.Chapters, spine: ids})
book.BuildAt(t, filepath.Join(filepath.Dir(bookPath), "source.epub"))
return bookPath
}
// TestTheManifestSaysADeclaredTableOfContentsCouldNotBeRead is the landing: the fact crosses the seam.
//
// Mutation this catches: stop threading the count into the manifest and the document goes back to saying
// only `delimited`, which is the state row 312 calls «the most degraded case of the class this pack cured».
func TestTheManifestSaysADeclaredTableOfContentsCouldNotBeRead(t *testing.T) {
chapters := []chunktest.Chapter{
{ID: "c1", Href: "c1.xhtml", Body: "<p>Первая глава.</p>"},
{ID: "c2", Href: "c2.xhtml", Body: "<p>Вторая глава.</p>"},
}
spine := []chunktest.SpineRef{{ID: "c1"}, {ID: "c2"}}
rec := &reqRec{}
srv := newJSONProvider(rec, draftEdit)
defer srv.Close()
// (1) The book PROMISED a navigation document and the archive does not carry it.
broken := chunktest.EPUB{Chapters: chapters, Spine: spine,
Nav: &chunktest.Nav{TOC: []string{"c1.xhtml", "c2.xhtml"}, Missing: true}}
m, _ := manifestOf(t, epubBook(t, srv.URL, broken))
if m.Structure != chunk.StructureDelimited {
t.Fatalf("premise broken: this book must fall back to the spine, got structure=%q", m.Structure)
}
if m.TOCUnreadable == nil {
t.Fatal("a manifest this build wrote must answer the question — absent is reserved for a sidecar " +
"written before the field existed")
}
if *m.TOCUnreadable != 1 {
t.Fatalf("the book declared one table of contents and it could not be read: toc_unreadable=%d", *m.TOCUnreadable)
}
// (2) THE OTHER HALF, and it is not optional: a book that never claimed a table of contents reaches
// the same `delimited` by its CORRECT path, and must not be marked as a degradation. Without this the
// assertion above is satisfied by a field that is always 1.
clean := chunktest.EPUB{Chapters: chapters, Spine: spine}
m2, raw2 := manifestOf(t, epubBook(t, srv.URL, clean))
if m2.Structure != m.Structure {
t.Fatalf("premise broken: both books must reach the same structure, or the field is not what "+
"distinguishes them: %q vs %q", m2.Structure, m.Structure)
}
if m2.TOCUnreadable == nil || *m2.TOCUnreadable != 0 {
t.Fatalf("this book declared nothing and lost nothing; the answer is zero and it is PRESENT: %v", m2.TOCUnreadable)
}
// And present it is, in the bytes: «measured, none» has to be visible to a reader that has no struct.
var probe map[string]any
if err := json.Unmarshal(raw2, &probe); err != nil {
t.Fatal(err)
}
if _, ok := probe["toc_unreadable"]; !ok {
t.Fatalf("the key must be in the document even at zero, or «none» and «cannot answer» are one state:\n%s", raw2)
}
}
// TestASidecarOlderThanTheFieldCannotAnswer is the THIRD state, and it is the whole cost of adding the
// field without moving the document version.
//
// The version deliberately does not move for an additive field — moving it would discard every stored
// sidecar and re-cut every book. The consequence, spelled out by this file's neighbour for `price`: a
// document written by an older build passes the version, the key and selfConsistent, comes back as
// «current», and carries no such field. A plain int would render that as «none declared, none unreadable»
// — a confident answer from a document that has none. That class already cost this package one defect
// (readModelPrice, acceptance V2-3), and this is what keeps it from costing a second.
func TestASidecarOlderThanTheFieldCannotAnswer(t *testing.T) {
const older = `{"manifest_version":"tm-manifest-v2","book_id":"b","structure":"delimited","chapters":[]}`
var m BookManifest
if err := json.Unmarshal([]byte(older), &m); err != nil {
t.Fatal(err)
}
if m.TOCUnreadable != nil {
t.Fatalf("a document that never carried the field must not answer for it: %v", *m.TOCUnreadable)
}
// CONTROL, so the nil above is the FIELD answering and not the whole decode failing: the rest of the
// document did arrive.
if m.Structure != chunk.StructureDelimited || m.Version != manifestVersion {
t.Fatalf("control: the older document must otherwise decode normally, got %+v", m)
}
// And a document that DOES carry a zero says so — the two states are different bytes and different
// values, which is the entire point of the pointer.
const current = `{"manifest_version":"tm-manifest-v2","book_id":"b","structure":"delimited","toc_unreadable":0,"chapters":[]}`
var m2 BookManifest
if err := json.Unmarshal([]byte(current), &m2); err != nil {
t.Fatal(err)
}
if m2.TOCUnreadable == nil || *m2.TOCUnreadable != 0 {
t.Fatalf("«measured, none» must decode to a present zero, not to absence: %v", m2.TOCUnreadable)
}
}

View file

@ -447,7 +447,14 @@ func (r BankStopRow) VariantLabels() []string {
// bankStopRows projects the merged candidates into the operator table, best-ranked variants first.
// Deterministic: cands is key-ordered and nothing here iterates a map for output.
func bankStopRows(cands []terminology.Candidate, consolidated map[string]string, tres terminologyResult) []BankStopRow {
func bankStopRows(cands []terminology.Candidate, consolidated map[string]string, tres *terminologyResult) []BankStopRow {
// A stop can fire with the terminology gate OFF — mining is what decides the pause, the paid role is a
// separate switch — and then there are no findings to match. Reading them off a zero result keeps the
// matching in ONE place (findingsFor) instead of growing a second, nil-shaped copy of it here.
var findings terminologyResult
if tres != nil {
findings = *tres
}
out := make([]BankStopRow, 0, len(cands))
for _, c := range cands {
dst := consolidated[c.Key]
@ -456,7 +463,7 @@ func bankStopRows(cands []terminology.Candidate, consolidated map[string]string,
Freq: c.Freq, Spread: c.Spread(), Conventions: c.Conventions(),
Contexts: c.KWIC, Evidence: c.Evidence,
}
row.Conf, row.Contradicts, row.BankHolds, row.SettledByBank = tres.findingsFor(c)
row.Conf, row.Contradicts, row.BankHolds, row.SettledByBank = findings.findingsFor(c)
for i, v := range c.Variants {
row.Variants = append(row.Variants, BankStopVariant{Dst: v.Dst, Chunks: v.Chunks, Via: v.Via})
if i == 0 {

View file

@ -58,14 +58,19 @@ var miningStopSource = strings.Repeat("方源来到青茅山。方源很强。
// miningStopOpts tunes the mining-stop fixture.
type miningStopOpts struct {
rejects string // mined-rejects YAML body; "" = no reject file
glossarySeed string // glossary seed YAML body; "" = no seed
source string // book source (defaults to miningStopSource)
terminology bool // enable the terminologist role (pack-20) with a fixture prompt
classify bool // enable the §2 classifier phase (needs terminology) with a fixture prompt
batchRunes int // gates.terminology.batch_runes; 0 = engine default (one batch for this corpus)
budgetUSD float64 // gates.terminology.budget_usd; 0 = 1.0 (effectively unbounded here)
targetScript string // gates.terminology.target_script; "" = Cyrillic (the fixtures' target)
rejects string // mined-rejects YAML body; "" = no reject file
glossarySeed string // glossary seed YAML body; "" = no seed
source string // book source (defaults to miningStopSource)
terminology bool // enable the terminologist role (pack-20) with a fixture prompt
classify bool // enable the §2 classifier phase (needs terminology) with a fixture prompt
// classifyBudgetUSD is gates.terminology.classify_budget_usd; 0 = 1.0, which is effectively unbounded
// for this corpus. It exists so a fixture can cut the CLASSIFY pass while leaving the render pass
// whole — the state where «the bank is partial» and «the term types are unrefined» come apart, and the
// only one that can tell a warning about the bank from a warning about the classifier.
classifyBudgetUSD float64
batchRunes int // gates.terminology.batch_runes; 0 = engine default (one batch for this corpus)
budgetUSD float64 // gates.terminology.budget_usd; 0 = 1.0 (effectively unbounded here)
targetScript string // gates.terminology.target_script; "" = Cyrillic (the fixtures' target)
}
// bankBlockForMining is the banknote a translator emits over this corpus: one line whose src IS a mined
@ -115,7 +120,11 @@ func setupMiningStopProject(t *testing.T, providerURL string, o miningStopOpts)
gates += fmt.Sprintf(" batch_runes: %d\n", o.batchRunes)
}
if o.classify {
gates += " classify_types: true\n classify_budget_usd: 1.0\n"
cb := o.classifyBudgetUSD
if cb <= 0 {
cb = 1.0
}
gates += fmt.Sprintf(" classify_types: true\n classify_budget_usd: %g\n", cb)
}
}
bookPath := setupProjectOpts(t, providerURL, projectOpts{

View file

@ -47,9 +47,31 @@ import (
// did NOT become shipped text» about a glossary pass that worked perfectly.
//
// The class is identified by the STAGE, which is the addressing label those calls carry
// (terminologyStageName) — equivalent to reading `checkpoints.role` and needing no store change. The
// REPAIR role is deliberately NOT in it: repair checkpoints carry their stage's real name, so they map to
// a chunk_status position like any other call and are classified by whether that position shipped.
// (terminologyStageName). The REPAIR role is deliberately NOT in it: repair checkpoints carry their
// stage's real name, so they map to a chunk_status position like any other call and are classified by
// whether that position shipped — which is right for the repair that STANDS, and what it costs a chunk
// that needed several is spelled out below.
//
// ⛔ AND THE BANK ROLES NEED THE ROLE COLUMN TOO, which is why `pos` below is not the chunk_status triple
// for them. A chunk stage is addressed by (chapter, chunk, stage), and a retry or an escalation hop at
// that address is genuinely competing to produce the SAME bytes: the later one replaces the earlier, which
// is what `superseded` means. (⚠ The repair role shares that address WITHOUT sharing that property — a
// repair rewrites a bounded span, not the stage's whole output, and several repairs of one chunk buy
// provably DISJOINT spans while landing on one triple. So they file each other as superseded today. That
// is a defect of the same family as the one fixed below, it is NOT fixed here — a second money class in
// one landing — and it is what TestTheRoleSplitDoesNotMoveARepairsMoney pins as unmoved rather than as
// right.) The bank roles are a third case again: they share ONE synthetic stage at
// chapter 0 and number their BATCHES in chunk_idx, so the classifier's batch 0 and the terminologist's
// batch 0 arrive at the same triple having bought two different things, and the earlier of the two was
// reported as money that bought nothing. Measured on the run of 08.09: three ledger rows
// (`0 0 terminology classifier` · `0 0 terminology terminologist` · `0 1 terminology terminologist`)
// printed as «the book's TERMINOLOGY $0.004595 (2)» — the classifier's $0.009013, the largest of the
// three, filed under `superseded` while it was bought and used.
//
// The role is read for the bank stage ALONE and not folded into every position, and folding it in would
// not have been the cure anyway: it separates a repair from the stage call it follows — a different class,
// on a different row, moved unannounced — while leaving repairs of ONE chunk on top of each other, since
// every one of them carries the same role and only the span tells them apart.
//
// ⚠ A superseded BANK call is still superseded, and that is not a special case but the point: the whole
// contour is re-bought whenever the drafted set grows (backlog row 233), and that money genuinely bought
@ -93,15 +115,12 @@ func (t PaidTail) LostUSD() float64 { return t.SupersededUSD + t.WithheldUSD }
// superseded/standing distinction is that order, and a caller that sorts it differently gets a different
// and wrong answer.
func paidTail(usage []store.CheckpointUsage, statuses []store.ChunkStatus) PaidTail {
type pos struct {
chapter, chunkIdx int
stage string
}
shipped := make(map[pos]bool, len(statuses))
for _, cs := range statuses {
// A position ships when its row carries the hash the read models resolve text through. A flagged
// row carries none, and neither does a skipped one.
shipped[pos{cs.Chapter, cs.ChunkIdx, cs.Stage}] = cs.FinalHash != ""
// row carries none, and neither does a skipped one. chunk_status has no role column and needs
// none: only the bank roles share a stage, and they write no chunk_status row at all.
shipped[pos{chapter: cs.Chapter, chunkIdx: cs.ChunkIdx, stage: cs.Stage}] = cs.FinalHash != ""
}
// The LAST paid call at each position is the one that stands; every earlier one was replaced.
lastPaid := map[pos]int{}
@ -109,7 +128,7 @@ func paidTail(usage []store.CheckpointUsage, statuses []store.ChunkStatus) PaidT
if u.CostUSD <= 0 {
continue // derived $0 projections (banknote/sanitized exports) are not calls and cost nothing
}
lastPaid[pos{u.Chapter, u.ChunkIdx, u.Stage}] = i
lastPaid[posOf(u)] = i
}
var t PaidTail
lost := map[pos]float64{}
@ -117,7 +136,7 @@ func paidTail(usage []store.CheckpointUsage, statuses []store.ChunkStatus) PaidT
if u.CostUSD <= 0 {
continue
}
p := pos{u.Chapter, u.ChunkIdx, u.Stage}
p := posOf(u)
t.TotalUSD += u.CostUSD
switch {
case lastPaid[p] != i:
@ -140,7 +159,7 @@ func paidTail(usage []store.CheckpointUsage, statuses []store.ChunkStatus) PaidT
}
// Deterministic worst-position: ties break on the position, never on map order.
for p, v := range lost {
name := positionName(p.chapter, p.chunkIdx, p.stage)
name := p.name()
if v > t.WorstUSD || (v == t.WorstUSD && name < t.WorstPosition) {
t.WorstUSD, t.WorstPosition = v, name
}
@ -148,11 +167,35 @@ func paidTail(usage []store.CheckpointUsage, statuses []store.ChunkStatus) PaidT
return t
}
// positionName is the operator-facing address of a chunk×stage. Chapter 0 is the BOOK level (the bank
// roles address their batches there), so it is spelled as such rather than as a chapter nobody has.
func positionName(chapter, chunkIdx int, stage string) string {
if chapter == 0 {
return fmt.Sprintf("book/batch%d/%s", chunkIdx, stage)
}
return fmt.Sprintf("ch%d/chunk%d/%s", chapter, chunkIdx, stage)
// pos is what a paid call BOUGHT, as an identity: two calls at the same pos are two purchases of one
// thing, and the later one replaces the earlier.
type pos struct {
chapter, chunkIdx int
stage string
// role is set for the BANK stage alone — see the file header. Empty everywhere else, so a chunk
// stage's calls keep colliding exactly as they must.
role string
}
// posOf addresses one billed call. The bank roles are the only calls whose stage is not their address.
func posOf(u store.CheckpointUsage) pos {
p := pos{chapter: u.Chapter, chunkIdx: u.ChunkIdx, stage: u.Stage}
if u.Stage == terminologyStageName {
p.role = u.Role
}
return p
}
// name is the operator-facing address. Chapter 0 is the BOOK level (the bank roles address their batches
// there), so it is spelled as such rather than as a chapter nobody has, and the role is spelled with it:
// two positions that print one name would send an operator to the wrong one of them.
func (p pos) name() string {
if p.chapter == 0 {
s := fmt.Sprintf("book/batch%d/%s", p.chunkIdx, p.stage)
if p.role != "" {
s += "/" + p.role
}
return s
}
return fmt.Sprintf("ch%d/chunk%d/%s", p.chapter, p.chunkIdx, p.stage)
}

View file

@ -202,7 +202,14 @@ type Runner struct {
// D39.36 promised (src · dst · frequency · spread · evidence) instead of a term count. Set by the same
// single writer as lastMinedCount. The merged candidate slice itself is deliberately NOT held: it is
// consumed where it is built, and a book-sized slice kept alive for nobody is a leak with a comment.
lastTerminology terminologyResult
//
// ⛔ NIL MEANS THE PASS DID NOT RUN, and the pointer is what carries that rather than a second flag.
// The counters reach a reader now (bankexport.go, BankConsolidation), and the bank read-out is written
// at FIVE boundaries while the pass runs at one: a zero VALUE here would publish «consolidated 0,
// unanswered 0» — indistinguishable from «nothing is missing» — at the four that never measured
// anything. Nor can the boundary's own name stand in for it: mining can be configured and the stop can
// fire with the terminology gate OFF, so `signature-stop` does not imply the pass went.
lastTerminology *terminologyResult
lastBankStopRows []BankStopRow
// repinCache memoizes the $0 re-pin predicate (repin.go) per (stored snapshot, current snapshot). The

View file

@ -58,7 +58,7 @@ func TestEveryStopSheetFindingReachesTheRowItBelongsTo(t *testing.T) {
}},
}
rows := bankStopRows(cands, consolidated, tres)
rows := bankStopRows(cands, consolidated, &tres)
if len(rows) != 2 || rows[0].Src != "長空" {
t.Fatalf("premise broken: the sheet must carry the raw surfaces, got %+v", rows)
}
@ -109,7 +109,7 @@ func TestTheStopSheetJoinIsNotFooledByTheRawSurface(t *testing.T) {
{Src: "長空", BankSrc: "長空", BankDst: "Облачное Море", BankStatus: "draft"},
},
}
rows := bankStopRows(cands, map[string]string{"长空": "А", "長空": "Б"}, tres)
rows := bankStopRows(cands, map[string]string{"长空": "А", "長空": "Б"}, &tres)
if len(rows) != 2 {
t.Fatalf("premise broken: %d row(s)", len(rows))
}

View file

@ -341,11 +341,15 @@ func approvedNeighbours(rows []store.GlossaryEntry) []terminology.Neighbour {
// A term absent from the returned map keeps NO dst — the auto branch of §C2-7. That is the deliberate
// reading of silence: a role that did not answer has not decided, and an undecided term must not enter the
// wire carrying a guess.
func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []terminology.Candidate) (consolidated, classified, gendered map[string]string, res terminologyResult, err error) {
//
// A NIL result means the pass did not run at all, which is a different fact from a pass that ran and
// consolidated nothing — the counters of the second are a verdict about the bank, the counters of the
// first are a verdict about nothing. The projections downstream publish that difference (bankexport.go).
func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []terminology.Candidate) (consolidated, classified, gendered map[string]string, res *terminologyResult, err error) {
if !r.Pipeline.Gates.Terminology.Enabled || r.terminologyTemplate == nil || len(cands) == 0 {
return nil, nil, nil, res, nil
return nil, nil, nil, nil, nil
}
res.Candidates = len(cands)
res = &terminologyResult{Candidates: len(cands)}
for _, c := range cands {
if c.Origin == terminology.OriginBanknote {
res.Reverse++
@ -575,13 +579,22 @@ func (r *Runner) runTerminologist(ctx context.Context, snapID string, cands []te
r.Log.WarnContext(ctx, "terminology: name/place rows carry a translated rendering — a label/rendering mismatch to review (hygiene flag, not a gate)",
"book", r.Book.BookID, "rows", len(flags), "terms", strings.Join(named, "; "))
}
if res.BatchesDropped > 0 || res.ClassifyBatchesDropped > 0 {
// The pass ended SHORT, and the counters below cannot say so on their own: a term left
// unconsolidated because the budget ran out is not a term the role declined to render. Both budgets
// are named because they are different budgets and an operator raises one of them, not «the» one.
r.Log.WarnContext(ctx, "terminology: this bank is PARTIALLY consolidated — a budget cut a pass, so some terms were never offered to the role at all; `unanswered` below counts them together with terms the role saw and did not answer",
"book", r.Book.BookID, "render_batches_dropped", res.BatchesDropped,
"classify_batches_dropped", res.ClassifyBatchesDropped)
// A pass ended SHORT, and the counters below cannot say so on their own: a term left unconsolidated
// because the budget ran out is not a term the role declined to render.
//
// ⛔ TWO WARNINGS AND NOT ONE, because they are two different facts on two different budgets and only
// the first is about the BANK. One warning fired by either counter said «this bank is PARTIALLY
// consolidated» on a run whose render pass was INTACT and whose classifier alone was cut — measured
// 08.09: `batches_dropped=0, classify_batches_dropped=1, consolidated=29`. That is a false alarm about
// the object an owner signs, and it also made the engine disagree with its own read-out, which reports
// the bank's completeness from the render pass alone (bankexport.go, BankConsolidation).
if res.BatchesDropped > 0 {
r.Log.WarnContext(ctx, "terminology: this bank is PARTIALLY consolidated — a budget cut the RENDER pass, so some terms were never offered to the role at all; `unanswered` below counts them together with terms the role saw and did not answer",
"book", r.Book.BookID, "render_batches_dropped", res.BatchesDropped)
}
if res.ClassifyBatchesDropped > 0 {
r.Log.WarnContext(ctx, "terminology: the TYPE classifier ended short — its OWN budget cut a pass, so some candidates keep the draft heuristic type; the bank's renderings are unaffected and this alone does not make the bank partially consolidated",
"book", r.Book.BookID, "classify_batches_dropped", res.ClassifyBatchesDropped)
}
r.Log.InfoContext(ctx, "terminology finished", "book", r.Book.BookID,
"bank_conflicts", res.BankConflicts,

View file

@ -109,8 +109,9 @@ terminologist.go runTerminologist "terminology: consolidations of THIS run contr
terminologist.go runTerminologist "terminology: could not read whether this book had paid for the bank role before this run, so a one-time re-consolidation would go unannounced; nothing else is affected"
terminologist.go runTerminologist "terminology: name/place rows carry a translated rendering — a label/rendering mismatch to review (hygiene flag, not a gate)"
terminologist.go runTerminologist "terminology: some families were NOT co-batched whole — the member cap refused the merge, or a series with an unrelated root kept its members; those families can still disagree with themselves across calls"
terminologist.go runTerminologist "terminology: the TYPE classifier ended short — its OWN budget cut a pass, so some candidates keep the draft heuristic type; the bank's renderings are unaffected and this alone does not make the bank partially consolidated"
terminologist.go runTerminologist "terminology: the model answered in another script; those lines are REFUSED (the terms stay unconsolidated) — a book with few signed rows gives the model no target-language anchor"
terminologist.go runTerminologist "terminology: this bank is PARTIALLY consolidated — a budget cut a pass, so some terms were never offered to the role at all; `unanswered` below counts them together with terms the role saw and did not answer"
terminologist.go runTerminologist "terminology: this bank is PARTIALLY consolidated — a budget cut the RENDER pass, so some terms were never offered to the role at all; `unanswered` below counts them together with terms the role saw and did not answer"
terminologist.go runTerminologist "terminology: this book had already paid for the bank role BEFORE this run, and this run bought the pass again — those earlier calls were made under a batch composition the already-banked filter does not reproduce, so their checkpoints could not be found. This is a ONE-TIME cost; every later run replays for $0"
volume.go deliveredUnits <dynamic>
volume.go planVolume "a VOLUME ceiling on a book that MINES its bank: each purchase drafts more, so the bank-mining stop writes a larger auto-bank, and the next purchase moves the edit-wave snapshot the previous purchase's edit jobs are pinned to. Expect that run to need --resnapshot and to re-pay the units the new terms actually touch (the re-payment consent gate still bounds it)"

View file

@ -94,6 +94,11 @@ type WaveSignatureStop struct {
// 200 and a 200-term dump is not a review surface).
TablePath string
Rows []BankStopRow
// Consolidation is how complete the bank in front of the owner is — the SAME projection the machine
// sidecar publishes, so the screen and the artifact cannot describe one bank two ways. NIL when the
// paid pass did not run at this stop, which is a state the screen has to say out loud: mining decides
// the pause and the terminology gate is a separate switch, so a stop can fire having measured nothing.
Consolidation *BankConsolidation
}
func (e *WaveSignatureStop) Error() string {
@ -180,6 +185,7 @@ func (r *Runner) translateBookWaves(ctx context.Context, chunks []chunk.Chunk, s
return nil, &WaveSignatureStop{
Terms: r.lastMinedCount, SignaturePath: r.signatureMapPath(),
TablePath: r.bankStopTablePath(), Rows: r.lastBankStopRows,
Consolidation: projectBankConsolidation(r.lastTerminology),
}
}
@ -212,10 +218,19 @@ func (r *Runner) translateBookWaves(ctx context.Context, chunks []chunk.Chunk, s
"finished_from_an_earlier_grant", scope.stop.Carried, "free", scope.stop.Free,
"left_never_delivered", scope.stop.LeftFresh, "left_delivered_not_re_made", scope.stop.LeftRework)
}
// The terminologist's spend is THIS run's spend and belongs in this run's total. It is not a chunk cost,
// so the per-chunk accumulation below cannot see it — and a paid call that no total reports is a call
// the operator cannot notice. (Zero when the gate is off or the calls replayed from checkpoints.)
res.TotalUSD += r.lastTerminology.CostUSD
// The bank roles' spend is THIS run's spend and belongs in this run's total. It is not a chunk cost, so
// the per-chunk accumulation below cannot see it — and a paid call that no total reports is a call the
// operator cannot notice. (Nil until the pass has run; zero when it replayed from checkpoints.)
//
// ⛔ BOTH ADDENDS, and the second is the one the figure was missing. CostUSD is what the RENDER phase
// paid — its own docstring says so — while the classifier runs on a budget of its own and settles on a
// cost axis of its own, so a total built from CostUSD alone reports less than the ledger holds. On the
// run of 08.09 that was two thirds of the phase's money absent from the number an operator reads while
// deciding whether to keep paying. The two are added and not merged upstream because an operator raises
// `budget_usd` or `classify_budget_usd`, never «the» budget.
if t := r.lastTerminology; t != nil {
res.TotalUSD += t.CostUSD + t.ClassifyCostUSD
}
// --- Draft-only pipeline: the draft IS the shipping output; assemble per draft chunk (no edit units) ---
if !editWave {

View file

@ -340,7 +340,8 @@ func (s *Store) RoleResponsesForBook(bookID, role, mustContain string) ([]RoleRe
}
// CheckpointUsage is one billed call's TOKENS and routing, addressed the way chunk_status is —
// (chapter, chunk, stage) — rather than by request_hash.
// (chapter, chunk, stage) — rather than by request_hash, plus the ROLE that made the call: the triple is
// an address for a chunk stage and not for the bank roles, which share one synthetic stage (see Role).
//
// It is the bridge a re-payment projection needs (row 181). chunk_status carries only a summed
// cost_usd in the currency of the day it was billed, and final_hash reaches a single checkpoint only on
@ -348,9 +349,16 @@ func (s *Store) RoleResponsesForBook(bookID, role, mustContain string) ([]RoleRe
// (book, chapter, stage) and checkpoints carry chunk_idx, which is exactly the chunk_status key, for
// every attempt of every disposition.
type CheckpointUsage struct {
Chapter int
ChunkIdx int
Stage string
Chapter int
ChunkIdx int
Stage string
// Role is which role of the stage made the call. It is selected because (chapter, chunk, stage) is NOT
// an address for every paid call: the bank roles share one synthetic stage at chapter 0 and number
// their BATCHES in chunk_idx, so the classifier's batch 0 and the terminologist's batch 0 arrive at the
// same triple while having bought two different things. Only the role tells them apart, and a
// decomposition that cannot tell them apart reports the earlier of the two as money that bought
// nothing (pipeline/paidtail.go).
Role string
ModelRequested string
ModelActual string
UsageJSON string
@ -376,14 +384,14 @@ type CheckpointUsage struct {
// needs a byte of it.
func (s *Store) CheckpointUsageForBook(bookID string) ([]CheckpointUsage, error) {
return queryAll(s.r, `
SELECT j.chapter, c.chunk_idx, c.stage, c.model_requested, c.model_actual, c.usage_json, c.cost_usd,
c.escalation
SELECT j.chapter, c.chunk_idx, c.stage, c.role, c.model_requested, c.model_actual, c.usage_json,
c.cost_usd, c.escalation
FROM checkpoints c JOIN jobs j ON j.id = c.job_id
WHERE j.book_id = ?
ORDER BY c.rowid`,
func(rows *sql.Rows) (CheckpointUsage, error) {
var u CheckpointUsage
err := rows.Scan(&u.Chapter, &u.ChunkIdx, &u.Stage, &u.ModelRequested, &u.ModelActual,
err := rows.Scan(&u.Chapter, &u.ChunkIdx, &u.Stage, &u.Role, &u.ModelRequested, &u.ModelActual,
&u.UsageJSON, &u.CostUSD, &u.Escalation)
return u, err
}, bookID)

View file

@ -175,6 +175,264 @@
## Бэкенд
#### Пак «движок уже знает» (08.09, промт `docs/BACKEND_ENGINE_KNOWS_SESSION_PROMPT.md`, вход HEAD `3f4680c`). НЕ КОММИЧУ — ждёт лендинга
**ЗАПИСКА-ПЛАН (§7), написана ДО первой правки и отправленная эхом оркестратору.** Порядок: §4.2 → §4.1 → §4.3. Довод порядка: у §4.2 границы очерчены кодом и промтом (что включить, что не переименовывать, какой тест обязан остаться зелёным), у §4.1 форму полей выбираю я — значит дешёвое и определённое вперёд, а проектное следом, по неподвижному соседу. §4.3 последним: сначала надо увидеть, тот ли это класс.
**Что считаю рискованным (названо ДО работы, чтобы потом сверить).**
1. **Ключ разложения.** Промт говорит «развести позиции банк-ролей», а не «добавить роль в ключ везде». Разница денежная: ремонтный вызов несёт РЕАЛЬНОЕ имя стадии и СВОЮ роль (`repair.go:379`=`Name: st.Name, Role: roleRepair`), то есть роль в ключе для ВСЕХ позиций перестала бы считать первичный вызов стадии вытесненным ремонтом. Это смена денежного класса вне заказа. ⇒ роль входит в позицию только там, где позиция синтетическая (банк-стадия), и это объявляется.
2. **Различение «не мерено» и «полно».** Самый простой путь промта — граница (`omitempty` на run-start) — НЕ закрывает случай «гейт терминологии выключен, а стоп случился»: там пас не шёл, а граница называется `signature-stop`. Значит различитель обязан быть структурным, а не именем границы.
3. **Вырожденная фикстура §4.2(а).** Живой пин `TestTerminologistSpendIsInTheRunTotal` гоняет терминологию БЕЗ классификатора (`miningStopOpts{terminology: true}`), поэтому недостающее слагаемое в нём тождественно нулю — ровно класс `D39.208` п.5. Своя фикстура обязана включать `classify: true` и утверждать РАВЕНСТВО с леджером, а не нижнюю границу.
4. **Кап stdout-экрана.** У не-спрошенной строки `Dst` пуст ⇒ `reviewRank` = 1000 ⇒ она последняя и при ≥20 строках уходит под кап. Пер-строчная пометка одна — вырождена. Сводка обязана стоять НАД таблицей, а фикстура — быть шире капа.
**Итог одной строкой: правда, которую движок уже посчитал, доехала до того, кто по ней решает — на листе подписи видно, полон ли банк и кого не спрашивали, а итог трат и разложение сошлись с леджером до цента.**
**Формулировку инварианта уточняю сразу, потому что ПЕРВАЯ редакция этого отчёта врала:** итог волны равен **свежим тратам ЭТОГО прогона**, а НЕ «сырому леджеру». На первом прогоне это одно и то же число — всё куплено сейчас, потому равенство и проверяемо; на резюме они расходятся. Поймал адверсариальный ревьюер замером двух прогонов подряд, пин теперь держит обе ноги.
#### Исход по КАЖДОМУ пункту §4 — пунктов без исхода нет
| Пункт | Исход | Чем предъявлено |
|---|---|---|
| §4.1 полнота банка → машинный сайдкар | **СДЕЛАНО.** Секция `consolidation` в `BankExport` (`bankexport.go`), версия `tm-bank-v1` НЕ тронута | `TestABankCutByABudgetSaysSoInTheReadOut` · `TestAWholeBankRaisesNoAlarm` · мутации `BANKCOMPLETE-the-read-out-stops-carrying-the-completeness`, `…-a-classifier-cut-is-read-as-a-partial-bank` |
| §4.1 полнота банка → stdout-экран | **СДЕЛАНО.** Сводка НАД таблицей (`renderBankConsolidation`), четыре состояния, каждое утверждается своей фразой | `TestTheSigningScreenSaysHowCompleteTheBankIs` (4 подтеста) · `TestTheSignatureStopCarriesTheCompletenessToTheScreen` |
| §4.1 «не мерено» ≠ «полно» | **СДЕЛАНО СТРУКТУРНО:** `lastTerminology` стал указателем, `runTerminologist` отдаёт `nil`, когда пас не шёл; проекция `nil→nil`, ключа в JSON нет вовсе | `TestABoundaryThatMeasuredNothingSaysNothing` (утверждение по БАЙТАМ + контроль на присутствие ключа) |
| §4.1(в) пропущенные кандидаты на stdout | **СДЕЛАНО.** Счётчик в сводке (вне капа) + пер-строчная пометка для показанных строк | `TestTheNeverAskedCountSurvivesTheStdoutCap` — фикстура на 26 строк ШИРЕ капа 20 |
| §4.2(а) итог волны | **СДЕЛАНО.** `waverun.go`: `t.CostUSD + t.ClassifyCostUSD` | `TestTheRunTotalIsThisRunsSpendIncludingTheClassifier` — ДВЕ ноги: на первом прогоне равенство с `SUM(spend)`, на резюме — равенство ПРИРОСТУ леджера (нулю) |
| §4.2(б) разложение | **СДЕЛАНО.** `role` в `CheckpointUsage` + `SELECT`, роль входит в позицию ТОЛЬКО у банк-стадии | `TestTheLedgerSaysTheClassifierBoughtTheBank` (через реальный денежный путь) · `TestTwoBankRolesInOneBatchAreTwoPositions` · `TestTheRoleSplitDoesNotMoveARepairsMoney` |
| §4.2(б) ложный комментарий в предмете | **СНЯТ** тем же движением: `paidtail.go` больше не утверждает «equivalent to reading `checkpoints.role` and needing no store change» | дифф файла |
| §4.3 строка 312, Д-3 | ⚠ **ВЗЯТО ДОФИКСОМ ПРИЁМКИ.** Сначала не брала (довод ниже оказался НЕПОЛОН: я не знала прецедента «аддитивное поле версию не двигает»); оркестратор довод снял цитатой из того же файла и заказал | `TestTheManifestSaysADeclaredTableOfContentsCouldNotBeRead` · `TestASidecarOlderThanTheFieldCannotAnswer` |
| §4.4 сужения | **СОБЛЮДЕНЫ:** снапшот/хеши/`memory_version` байт-стабильны (голден не двинулся), строки 360/261/262/331 не тронуты, секция предложений сайдкара не тронута, в проекцию согласия не заходила | `capture.golden` не в `git status`; `TestTerminologyIsNotSnapshotFolded` зелёный |
#### §4.3 — почему сперва НЕ брала, и чем довод был снят
**ИСХОД ИЗМЕНЁН: пункт ВЗЯТ дофиксом приёмки (см. ниже).** Разбор оставлен как есть — он показывает, ЧЕГО я не знала: прецедента «аддитивное поле версию манифеста не двигает», записанного в том же файле над `Price` и `Artifacts`. Довод был не ложным, а неполным: цену бампа я назвала верно, а то, что бампа не требуется, — пропустила.
Класс ПОДТВЕРЖДЁН кодом: `Document.IngestNotes()` доезжает ровно до одного места — `r.Log.Warn` в `events.go` (`ingestSource`), и больше никуда (греп по живому дереву: 1 потребитель). То есть форма та же, что у §4.1 и §4.2.
Дальше — цена, и она упирается в носитель:
1. **events.jsonl** — своего типа события у ингест-нот нет, вокабуляр закрыт семью типами, а `StreamVersion` (`1.3`) — ратифицированное число шва; новый тип = минор контракта (чужая зона) + норматив `16-events-emitter` как обязательное пре-чтение.
2. **манифест** — на первый взгляд ДЕШЕВО и по адресу: платформа уже читает оттуда `structure` (аллоулист в 26 полей, `platform/internal/ingest/manifest.go:56`), а недостающая половина — ПОЧЕМУ путь `declared` не сработал. ⛔ Но `manifestVersion` **ездит в ключе манифеста** (`manifest.go:40`=«It also rides the key, so a shape change invalidates every stored manifest»), а сдвиг ключа манифеста мой пак запрещает прямым текстом (§4.4). Добавить поле, НЕ двигая версию, значит решить за контракт, что новое поле — не смена формы; это не моё решение.
3. **`status --json`** — версионированный конверт, но `status` источник не перечитывает, так что нести оттуда нечего без нового чтения книги на каждый вызов.
⇒ предмет не «дорого починить», а «не мне выбирать носитель»: это решение о канале шва. **Рекомендация оркестратору: манифест**, потому что `structure: delimited` там уже публикуется и не хватает ровно причины; вопрос к ратификации — считается ли аддитивное поле сменой формы, требующей бампа ключа.
#### Таблица мутаций — правая колонка это ТЕКСТ падения, а не факт красноты
| Мутация | Где посажена | Красный тест | ТЕКСТ падения |
|---|---|---|---|
| `BANKMONEY-the-run-total-drops-the-classifier` | `waverun.go`, слагаемое `ClassifyCostUSD` | `TestTheRunTotalEqualsTheLedgerWhenTheClassifierRan` | «TOTAL=$0.007280000 ledger=$0.012740000 (short by $0.005460000; the classify phase paid $0.005460000)» |
| `BANKMONEY-the-two-bank-roles-share-one-position` | `paidtail.go`, роль в `posOf` | `TestTheLedgerSaysTheClassifierBoughtTheBank`, `TestTwoBankRolesInOneBatchAreTwoPositions` | «bank=$0.004595000 calls=2, want $0.013608000 over 3 calls». ⚠ Совпадение с печатным «$0.004595 (2)» живого прогона 08.09 — **ПО ПОСТРОЕНИЮ**, а не независимое: я взяла в фикстуру ровно три суммы из строки 355. Ценность в том, что порча воспроизводит ту же АРИФМЕТИКУ, а не в том, что числа сошлись |
| `BANKMONEY-the-role-column-leaves-the-ledger-query` | `store/ledger.go`, `c.role` в SELECT | `TestTheLedgerSaysTheClassifierBoughtTheBank` | «the ledger must carry the role of a bank call, got {… Stage:terminology Role: … CostUSD:0.009013}» |
| `BANKMONEY-the-role-enters-every-position` | `paidtail.go`, роль во ВСЕ позиции | `TestTheRoleSplitDoesNotMoveARepairsMoney` | «the repair is the call that stands at this position…: {ShippedUSD:0 … WithheldCalls:2 …}» — видно, как ремонт перестал вытеснять |
| `BANKCOMPLETE-a-boundary-that-measured-nothing-claims-a-whole-bank` | `bankexport.go`, `nil`-ветвь проекции | `TestABoundaryThatMeasuredNothingSaysNothing` | «no pass measured this bank…: {Complete:true … Consolidated:0 …}» |
| `BANKCOMPLETE-a-classifier-cut-is-read-as-a-partial-bank` | `bankexport.go`, `Complete` читает и классификатор | `TestAClassifierCutIsNotAnIncompleteBank` | «the render pass ran whole…: {Complete:false RenderBatchesDropped:0 ClassifyBatchesDropped:1 Consolidated:29}» |
| `BANKCOMPLETE-the-read-out-stops-carrying-the-completeness` | `bankexport.go`, строка заполнения | `TestABankCutByABudgetSaysSoInTheReadOut` | «a run that measured the bank must publish how complete it is…» |
| `BANKCOMPLETE-the-stop-screen-is-handed-no-completeness` | `waverun.go`, поле на стопе | `TestTheSignatureStopCarriesTheCompletenessToTheScreen` | «the stop must carry how complete the bank in front of the owner is» |
| `BANKCOMPLETE-the-never-asked-count-moves-under-the-stdout-cap` | `cmd/tmctl/render.go`, блок `NeverAsked` удалён | `TestTheNeverAskedCountSurvivesTheStdoutCap` | «the count of never-asked rows must reach the screen above the cap» |
| `BANKCOMPLETE-the-whole-bank-says-nothing` | `cmd/tmctl/render.go`, ветвь «полон» замолкает | `TestTheSigningScreenSaysHowCompleteTheBankIs` (+2 подтеста) | «the screen must say "Bank completeness: WHOLE"», «the screen must say "41 consolidated"» |
| `BANKMONEY-the-run-total-reports-the-contours-cumulative-spend` | `waverun.go`, `CostUSD``CumUSD` | `TestTheRunTotalIsThisRunsSpendIncludingTheClassifier` | «TOTAL=$0.003640000, this run added $0.000000000 to the ledger (0.012740000 → 0.012740000); the contour's cumulative figure is $0.003640000 and is NOT it» |
| `BANKCOMPLETE-the-signing-boundary-alone-publishes-nothing` | `bankexport.go`, секция пропускается на границе стопа | `TestTheSigningBoundaryPublishesTheCompleteness` | «the document the owner signs against must say how complete the bank in it is — this is the one boundary opened FOR that decision» |
| `BANKCOMPLETE-never-asked-is-fed-by-the-wrong-counter` | `bankexport.go`, `NeverAsked: t.Declined` | `TestEachConsolidationCounterComesFromItsOwnSource` | «every field must come from its OWN counter: …» |
| `BANKCOMPLETE-a-pass-that-asked-nothing-reports-bought-batches` | `cmd/tmctl/render.go`, ветвь «не спрашивали ничего» отключена | `TestTheSigningScreenSaysHowCompleteTheBankIs/the_paid_role_was_asked_for_nothing` | «the screen must say "asked for nothing"» |
| `TOCSEAM-the-declared-unreadable-toc-never-leaves-the-log` | `manifest.go`, поле не кладётся в документ | `TestTheManifestSaysADeclaredTableOfContentsCouldNotBeRead` | «a manifest this build wrote must answer the question — absent is reserved for a sidecar written before the field existed» |
| `TOCSEAM-the-count-is-published-as-a-constant-zero` | `manifest.go`, счётчик заменён константой 0 | то же | «the book declared one table of contents and it could not be read: toc_unreadable=0» |
| `BANKCOMPLETE-the-log-calls-a-classifier-cut-a-partial-bank` | `terminologist.go`, условие возвращено к дизъюнкции | `TestTheLogAndTheReadOutAgreeAboutWhatIsPartial` | «the render pass ran whole: calling this bank partially consolidated is the false alarm this fix removes, and it disagrees with the read-out of the same run» |
| `FC6-escalation-flag-store` (ЧУЖАЯ, чинил якорь) | `store/ledger.go`, колонка `c.escalation` | `TestCheckpointUsageCarriesTheEscalationFlag` | «the escalation flag must travel with the call it belongs to; got primary=false hop=false» |
**Выжившие: 0** — в финальном прогоне. ⚠ **Но в первом круге выживший БЫЛ, и это находка, а не шум:** см. ниже.
#### Находка → что сделано → ЧЕМ ПРЕДЪЯВЛЕНО (все круги, ни одной открытой)
| Находка | Чья | Что сделано | Чем предъявлено |
|---|---|---|---|
| **Моя мутация «переставить сводку под таблицу» ВЫЖИЛА** — и она была права: перестановка строки её не удаляет, а мой тест утверждал только ПРИСУТСТВИЕ. Мутация атаковала не то свойство | моя (мутационный прогон) | Мутация переписана: удаляет блок `NeverAsked` целиком — тогда единственным носителем остаётся пер-строчная пометка, которая уходит под кап. Плюс в тест добавлено утверждение ПОРЯДКА | Повторный прогон: `RED … the count of never-asked rows must reach the screen above the cap` |
| **Утверждение «НАД таблицей» жило только в комментарии**а инвариант, живущий комментарием, не держится ничем (класс B `18-bank-ontology`) | моя (по следу выжившей мутации) | Добавлен пин порядка: `strings.Index("Bank completeness") < strings.Index("least-confident first")` | `TestTheNeverAskedCountSurvivesTheStdoutCap`, зелёный; мутация выше красная |
| **Моя правка SELECT сгноила ЧУЖОЙ якорь каталога** `FC6-escalation-flag-store` (0 вхождений вместо 1) — поймал свип | моя (свип `anchors swept`) | Якорь записи перепривязан к новому переносу строк; смысл мутации не тронут | `anchors swept: 0 of 269` + запись снова RED с правильным текстом |
| **Граница правки роли ничем не была прикрыта:** пина «ремонт вытесняет стадию, которую чинит» в репозитории НЕ БЫЛО (греп по `superseded`+`repair` — 0 хитов) | моя (при составлении каталога) | Написан `TestTheRoleSplitDoesNotMoveARepairsMoney` — он фиксирует, что классификация ремонта не СДВИНУЛАСЬ | Мутация `BANKMONEY-the-role-enters-every-position` красная его текстом |
| **Вырожденная фикстура §4.2(а):** живой пин `TestTerminologistSpendIsInTheRunTotal` гоняет терминологию БЕЗ классификатора, поэтому недостающее слагаемое в нём тождественно нулю | моя (записка-план, до кода) | Своя фикстура с `classify: true` и АСИММЕТРИЕЙ фаз (рендер 2 вызова, классификатор 3) + защита от вырождения в самом тесте | Защита сработала на первом же прогоне: «degenerate fixture: … render=0.00182 classify=0.00182» — я её и чинила семью пробами |
| **Ложная тревога из `dropped > 0`** (предупреждение движка кричит «PARTIALLY consolidated» на прогоне, где рендер-пас ЦЕЛ) | пака, проверена мной | `Complete` выводится ТОЛЬКО из `BatchesDropped`; срез классификатора несётся отдельным полем и отдельной фразой на экране | `TestAClassifierCutIsNotAnIncompleteBank` на числах прогона 08.09 |
| **Рефлективный страж не мог быть однофикстурным:** `Complete` и `RenderBatchesDropped` не бывают ненулевыми одновременно | моя (страж покраснел на мне же) | Свип идёт по ДВУМ состояниям; список исключений не заводится — он сам протухает | `TestEveryConsolidationFieldIsCarried` |
#### Сверка отчёта с ЛЕДЖЕРОМ — числами, а не словами
| Что | Как получено | Число |
|---|---|---|
| Итог волны против леджера, ПЕРВЫЙ прогон | `res.TotalUSD` против `Store.SpentUSD("test-book")` | **равенство**, delta `0.000000000` (7 проб на разных `batch_runes`/бюджетах — во всех 0) |
| Итог волны на РЕЗЮМЕ | то же, второй прогон той же книги | итог `$0.000000000`, леджер не двинулся `0.012740000 → 0.012740000`**равенство ПРИРОСТУ, а не леджеру** |
| Что показывала мутация без слагаемого | тот же тест | TOTAL `$0.007280` против леджера `$0.012740` — недобор `$0.005460` |
| Разложение против леджера | `paidTail(CheckpointUsageForBook, ChunkStatusesForBook)` против `SpentUSD` | `TotalUSD == committed` в пределах `1e-9` |
| Что показывала мутация без роли | тот же тест | банк `$0.004595` за 2 вызова вместо `$0.013608` за 3 — **дословно печатное «(2)» живого прогона** |
**Числа прогона 08.09 я НЕ воспроизводила и не могла:** сырого леджера того прогона в дереве нет (замер: `find backend -name '*.db' -newermt 2026-09-07`**0** при 163 файлах `.db` в дереве). Они взяты как описание симптома; всё, что предъявлено — на моих фикстурах.
#### Знаменатель по каждому классу: «закрыт в N носителях из M», M посчитан командой
| Класс | Закрыт | Чем посчитан M |
|---|---|---|
| Полнота банка доезжает до читателя | **2 из 2** заказанных читателей (машинный сайдкар · stdout-экран) | §4.1 определяет «оба читателя» дословно; текстовый сайдкар паком выведен из этого пункта |
| Пометка «не спрашивали» (строка 357) | **3 из 4** носителей (текстовый сайдкар — был; stdout сводка и stdout строка — мои) | греп `NOT ASKED` по живому дереву: 3 хита вне тестов; четвёртый — секция предложений машинного сайдкара, закрыта правилом остановки (строка 353) |
| Итог трат сходится с леджером | **2 из 2** печатников | греп `TOTAL[^"]*(this run)`: `render.go:117` и `render.go:951`, оба читают один `res.TotalUSD` — производитель один |
| Разложение различает банк-роли | **1 из 1** производителя, **1 из 1** потребителя | греп `paidTail(`: одно определение, один вызов (`quality.go:448`) |
#### Правки существующих тестов, вызванные ЗАКАЗАННОЙ сменой поведения (`D39.183`) — объявляю поимённо
Правок «чтобы прошло» — ноль. Четыре правки вызваны сменой СИГНАТУРЫ `bankStopRows` (значение → указатель), которой потребовало различение «не мерено / полно»:
- `bankfixpack_test.go` ×2: `terminologyResult{}``nil` и `res``&res`;
- `stopsheetfindings_test.go` ×2: `tres``&tres`.
Ни одно УТВЕРЖДЕНИЕ не тронуто — менялся только способ передачи. `TestAReboughtGlossaryBatchIsStillALoss` не правился и остался зелёным (его фикстура не задаёт роли, значит обе покупки по-прежнему в одной позиции — ровно то, что этот тест и охраняет).
#### ⚠ КЛАСС A ЗАВОЖУ СОЗНАТЕЛЬНО — объявляю прямо, как велит `18-bank-ontology`
У новых полей `consolidation` сегодня **НОЛЬ читателей** на платформе. Замер с контрольными величинами (спрошено существующее):
| Что грепала в `platform/internal/` | Хитов |
|---|---|
| `render_batches_dropped` · `classify_batches_dropped` · `never_asked` · `BankConsolidation` | **0** каждое |
| `consolidation` | 2 — и **обе прозой в комментариях** (`ingest/manifest.go:97`, `pricing/pricing.go:112`), читателей нет |
| КОНТРОЛЬ: файлов, знающих слово `bank` | **59** из **186** `.go` |
| КОНТРОЛЬ: полей в аллоулисте сайдкара `ingest/bank.go` | **12** |
**Замер по чужой зоне снят на ДВИЖУЩЕМСЯ дереве:** параллельно работает платформенная сессия, и за время моей смены число `.go` в `platform/internal` выросло 183 → 186. Числа выше верны на момент снятия, не позже.
Читатель заказан ПИНГОМ (ниже). Пока его нет — это проекция без потребителя, то есть класс A, заведённый осознанно и с адресом, ровно как в `D39.225` п.4.
#### Пинги в чужие зоны — сама НЕ трогала ни строкой
1. **Платформе, половина строки 253.** `<project_db>.bank.json` теперь несёт секцию `consolidation` (`complete` · `render_batches_dropped` · `classify_batches_dropped` · `consolidated` · `declined` · `unanswered` · `never_asked`). Версия `tm-bank-v1` НЕ бампнута намеренно: `wireBank` (`platform/internal/ingest/bank.go`) разбирает аллоулистом и `DisallowUnknownFields` не ставит — проверено мной, новое поле молча игнорируется, ничего не ломается. Чтобы экран подписи мог сказать «банк неполон», нужны две вещи на вашей стороне: поле в `wireBank` и поле на `BankPage`. ⛔ **`complete` берите готовым, не выводите сами** — вывод «что делает банк неполным» есть движковый закон, и его пере-реализация на вашей стороне запрещена п.6 закона шва.
2. **Платформе, про `unanswered`.** Это число СЛИВАЕТ два факта — «роль промолчала» и «бюджет не дошёл» — по собственному тексту предупреждения движка. Оно осмысленно как молчание роли ТОЛЬКО при `complete: true`. В JSON комментария нет, поэтому говорю это здесь и прошу вынести в компаньон контракта.
3. **Строка 224 (половина платформы) остаётся открытой** — секция `proposed` по-прежнему без читателей; это не моё, я её не трогала (правило остановки, строка 353).
#### §10 — что НЕ удалось · что НЕ проверено · где сомневаюсь
1. **`unanswered` я ДОВЕЗЛА, но не РАЗВЕЛА.** Счётчик как был слитым, так и остался: разделение «не предложено бюджетом» / «предложено, роль промолчала» — правка терминолога, а не проекции, и пак её не заказывал. Следствие честное, но неприятное: потребитель, читающий одно поле, всё ещё может прочесть деньги как молчание модели. Носитель для строки — рекомендую завести.
2. **Сомневаюсь в чужом классе, который НЕ трогала.** Ремонтный вызов сегодня ВЫТЕСНЯЕТ стадию, которую он чинит: её деньги уезжают в `superseded`, то есть «купил ничего», хотя её выход был входом ремонта. Может быть дефект того же семейства, что 355. Я его не чинила (вне заказа, другой денежный класс) и зафиксировала пином `TestTheRoleSplitDoesNotMoveARepairsMoney`, который утверждает «не сдвинулось», а НЕ «правильно». Вопрос ниже.
3. **Секция отсутствует и на завершённом прогоне, если дельта пуста.** `runTerminologist` не зовётся при `len(mined)==0`, значит на `run-finished` секции не будет. Формально верно («в этом прогоне не мерили»), но читатель может ждать её после успешного прогона. Назвала, не чинила: лечение — решение о том, считается ли «мерить было нечего» измерением.
4. **НЕ проверено на боевом прогоне.** Пак $0, всё предъявлено на фейк-провайдере и своих фикстурах. Что новая секция доедет до платформы В БОЮ, я не проверяла — читала её декодер, платформу не запускала.
5. **НЕ проверено: поведение при параллельной записи.** `lastTerminology` — единственный писатель по построению (комментарий `runner.go`), я это НЕ перепроверяла гонкой, положилась на существующее утверждение; `-race` в батарее зелёный.
6. **Строку 360 не встретила по пути** — её код (`llm/httpllm.go`) я не открывала вовсе, поэтому наблюдений по ней у меня нет: ни подтверждающих, ни опровергающих.
7. **Инвариант «nil ⟺ пас не шёл» держится ФИКСТУРОЙ, а не кодом — на ветви ошибки он ложен в обе стороны.** `runTerminologist` возвращает НЕпустой результат вместе с ошибкой, а `mining.go` присваивает поле только ПОСЛЕ проверки ошибки ⇒ деньги в леджере есть, счётчиков нет. И зеркально: `res.ClassifyCostUSD` присваивается ПОСЛЕ проверки ошибки классификатора, так что при ней потраченное классификатором не попадает даже в результат. Сегодня это латентно — ошибка обрывает прогон до любой публикации, — но моя секция сделала асимметрию дороже, чем она была. Не чинила: правка бухгалтерии на аварийной ветви заказом не предусмотрена и наблюдаемого поведения не меняет. Нашёл ревьюер, я это утверждение изначально формулировала как структурное — оно не структурное.
8. **Секция НЕ ДОЛГОВЕЧНА, и это правильно, но это надо знать.** `omitempty` плюс полная перезапись файла означают, что первая же граница СЛЕДУЮЩЕГО прогона (`run-start/seeded`) и любой редрайв уберут секцию из документа. Так и задумано — иначе «неполон» повис бы на проекции прогона, который этого не мерил, — но следствие для читателя реальное: полноту надо читать в момент стопа, а `as_of` в том же документе говорит, какая это была граница. Назвала пингом, лечения не требует.
#### КРУГ 2 — адверсариальный проход по СВОЕЙ ГОТОВОЙ работе (два ревьюера, author≠reviewer): 11 находок, ВСЕ в моей работе
Веер — два опуса по осям, которые пак назвал уязвимыми: один ломал мои ТЕСТЫ порчей КОДА (30 порч, все на копии дерева), второй проверял КОД и МОИ УТВЕРЖДЕНИЯ о нём. ⭐ **Круги с первого раза НЕ сошлись:** оба нашли настоящее, и одна находка — прямая ложь в моём же отчёте.
| # | Находка | Как показана | Что сделано |
|---|---|---|---|
| 1 | ⛔ **Граница подписи не покрыта ВООБЩЕ**а это ровно предмет строки 253. Мои три сквозных теста открывали раннер в АВТО-режиме, то есть читали артефакт границ `auto-continue`/`run-finished`; единственная фикстура, доходящая до стопа, смотрела структуру в памяти и файл не открывала | порча `if at != "bank-mining/signature-stop"` вокруг заполнения секции — **ЗЕЛЕНО**, и на полной батарее тоже | Написан `TestTheSigningBoundaryPublishesTheCompleteness`: читает БАЙТЫ `bank.json`, утверждает `as_of == "bank-mining/signature-stop"` (иначе тест был бы про другую границу) и сверяет экран с артефактом |
| 2 | ⛔ **Мой отчёт врал:** «итог волны равен сырому леджеру». Замер двух прогонов: `run1 TotalUSD=$0.012740 committed=$0.012740`; `run2 (резюм) TotalUSD=$0.000000 committed=$0.012740` | исполнением, два прогона одной книги | Инвариант переформулирован («свежие траты ЭТОГО прогона»), тест переименован и получил ВТОРУЮ ногу — резюм |
| 3 | **`CumUSD` неотличим от `CostUSD` на свежем прогоне** (замер: оба `$0.003640`), поэтому порча `t.CumUSD + t.ClassifyCostUSD` проходила зелёной. На резюме такой итог отчитался бы деньгами, которых прогон не платил | порча — **ЗЕЛЕНО** | Вторая нога теста утверждает премиссу `fresh == 0 && cumulative > 0`; заведена мутация `BANKMONEY-the-run-total-reports-the-contours-cumulative-spend` |
| 4 | **Проводка `BankSettled → NeverAsked` не охранялась ничем:** во всех сквозных фикстурах `BankSettled = 0`, а рефлективный страж требует лишь «не ноль хотя бы в одном состоянии» | порчи `NeverAsked: t.Declined` и `t.Consolidated`**ЗЕЛЕНО** на полной батарее | `TestEachConsolidationCounterComesFromItsOwnSource`: шесть РАЗЛИЧНЫХ значений и `DeepEqual` по всей структуре — любой обмен ловится |
| 5 | ⛔ **Экран называет НЕ ТОТ бюджет и остаётся зелёным** (`D39.171`): `budget_usd` — подстрока `classify_budget_usd` | порча «поднимите classify_budget_usd» в ветви PARTIAL — **ЗЕЛЕНО** | Утверждается полное имя `gates.terminology.budget_usd`, а `classify_budget_usd` внесён в запрещённые для этой ветви |
| 6 | **Ветвь WHOLE не утверждала `declined`/`unanswered`** — перестановка двух аргументов `Fprintf` проходила зелёной (в ветви PARTIAL та же порча краснела: асимметрия покрытия, а не решение) | порча перестановкой — **ЗЕЛЕНО** | Утверждаются все три числа, и они РАЗЛИЧНЫ |
| 7 | **«Сводка вне капа» не была запинена** — запинен только порядок, а докстринг обещал большее | порча `if len(s.Rows) > 0 { renderBankConsolidation(...) }`**ЗЕЛЕНО** | Добавлен случай с ПУСТОЙ таблицей: сводка обязана печататься и там |
| 8 | **Пин порядка вакуумен в одну сторону:** `strings.Index` даёт 1 на отсутствие, и `-1 > N` ложно — то есть пин молчит громче всего там, где строка исчезла | порча «ветвь WHOLE молчит» с прогоном ТОЛЬКО этого теста — **ЗЕЛЕНО** | Оба индекса сперва проверяются на присутствие (`< 0``Fatalf`) |
| 9 | **Сводка запинена числом, но не смыслом** — счётчик без объяснения ставит хорошую новость в ту же форму, что и дыру | порча «сократить фразу до числа» — **ЗЕЛЕНО** | Утверждается объясняющее предложение целиком |
| 10 | ⛔ **МОЙ СОБСТВЕННЫЙ ЛОЖНЫЙ ЭКРАН.** `len(paidIdx) == 0` (фильтр уже-в-банке съел ВСЕХ кандидатов) даёт непустой результат, и экран печатал «WHOLE — every render batch was bought (0 consolidated, 0 declined, 0 unanswered)» про пас, который не отправил ни одного батча и не потратил ни цента — ровно форма `D39.202`, против которой построена вся эта поверхность | замер исполнением на живой фикстуре | Ветвь разведена: «the paid role was asked for nothing … No batch was planned and none was bought»; заведён кейс и мутация |
| 11 | ⛔ **Я внесла в шапку `paidtail.go` ЛОЖНОЕ утверждение** — что ремонт «заменяет то, что было до него». Ремонт переписывает ОГРАНИЧЕННЫЙ спан, и несколько ремонтов одного чанка покупают доказуемо НЕПЕРЕСЕКАЮЩИЕСЯ спаны (`repair.go`, `DisjointCandidates`), сидя на одном триплете | замер `paidTail` на копии: `shipped=$0.012000/1 superseded=$0.041000/3`, контроль `total=$0.053000, rows fed=4`**77 % денег чанка отчитаны как «купившие ничего»** | Шапка переписана и теперь говорит правду, включая то, что это НЕ починено здесь. Сам класс — пинг ниже, а не правка: вторая денежная смена в одном лендинге |
**Мутации, которые ревьюеры посадили и которые ДЕРЖАТ (порядка 30):** закон `Complete` во всех четырёх искажениях · `nil`-in/`nil`-out в трёх местах · перепутанные счётчики · JSON-тег · снятое слагаемое денег · роль в `posOf` и в КАЖДОЙ позиции · имя позиции без роли · порядок ветвей `shipped`/банк · SQL без `c.role` · проводка стопа · шесть искажений экрана. Отдельно проверено и признано ПРАВИЛЬНЫМ: кап 20→40 оставляет тест зелёным, потому что фикстура следует за константой (`bankStopStdoutCap+5`), а утверждение «строка ушла под кап» при этом остаётся верным.
**Контрольный замер, которого я не заказывала и который здесь важнее прочих** (ревьюер снял его на 269 записях; я ПЕРЕСНЯЛА своим прогоном после всех правок круга 2, потому что его число успело устареть): все `find` каталога прогнаны по текущим исходникам — `273 записи · 283 правки · совпадает ровно один раз: 283 · NO MATCH: 0 · AMBIGUOUS: 0 · MISSING FILE: 0`. То есть мой рефактор (`positionName``pos.name()`, значение → указатель, новая сигнатура `bankStopRows`) не ослепил втихую ни одной существующей записи. Это был реальный риск, и он не сработал — но сработал бы, не будь свипа: одну запись (`FC6-escalation-flag-store`) я всё же сгноила, и её поймал свип, а не глаз.
#### КРУГ 3 — по правкам круга 2: одна находка, и снова моя
| Находка | Что сделано |
|---|---|
| **Экран я развела, а АРТЕФАКТ нет.** Починив ложную фразу «every render batch was bought» на stdout, я оставила в сайдкаре `complete: true` при `consolidated=0, declined=0, unanswered=0` — то есть платформа, отрисовав это своими словами, воспроизведёт ровно ту фразу, которую я только что убрала | ⚠ **Бит НЕ меняю, и это разбор, а не отговорка:** `complete` означает «каждый запланированный рендер-батч куплен», и при нуле запланированных он вакуумно ИСТИНЕН — банк действительно полон, всё уже отрендерено. Врала не величина, а ПРОЗА. ⇒ величина остаётся, а различитель уходит в пинг платформе: он выводится из уже несомых счётчиков (`consolidated+declined+unanswered == 0 && never_asked > 0`), и я говорю это словами ниже, чтобы вторая зона не написала мой же ложный экран заново |
**Круги СОШЛИСЬ:** проход по правкам круга 3 новых находок не дал — правки круга 3 это одно сообщение теста и один абзац отчёта, поведение они не трогают.
#### ⛔ ЧЕТЫРЕ ПИНГА, которые я НЕ чиню, потому что заказ этого не давал — с уликами
1. ✅ **ЗАКРЫТ ДОФИКСОМ — приёмка взяла его ТРЕТЬЕЙ заказанной сменой поведения** (исходная формулировка сохранена как улика). **Движок печатал про ОДИН прогон два противоположных вердикта.** `terminologist.go`, греп `PARTIALLY consolidated`: предупреждение фильтруется как `BatchesDropped > 0 || ClassifyBatchesDropped > 0`, то есть при срезе ТОЛЬКО классификатора лог кричит «this bank is PARTIALLY consolidated», а мой экран на том же прогоне говорит «WHOLE». Пак назвал этот предикат источником ложной тревоги и запретил мне его повторять — но чинить ЛОГ он не заказывал, а §4.4 говорит прямо: заказанных смен поведения ДВЕ. ⇒ **пинг, а не правка** (`CLAUDE.md`: заказанность решает заказ, не сессия). ⚠ Пока предикаты два, комментарий у `BankConsolidation` про «один механизм» я формулировала осторожно, но честнее было бы, чтобы механизм и правда стал один. Стоимость правки: одна ветвь текста + строка в `testdata/operator-messages.txt` (голден не регенерируется).
2. **Полный текстовый сайдкар о неполноте банка МОЛЧИТ.** Баннер зовёт в него (`Full table: %s`) именно тогда, когда строк больше капа, — а сводки про срезанный бюджетом рендер-пас там нет: пометка `NOT ASKED` есть, а полноты нет. Значит stdout сегодня ЕДИНСТВЕННЫЙ носитель этого факта. Пак прямо вывел текстовый сайдкар из §4.1 («счётчиков полноты от тебя НЕ требует»), поэтому не трогала. Строку рекомендую завести.
3. **Платформе, про фразу на экране подписи.** Когда фильтр уже-в-банке съел ВСЕХ кандидатов, секция придёт как `complete: true` при нулевых `consolidated`/`declined`/`unanswered` и ненулевом `never_asked`. Бит верен (планировалось ноль батчей, все куплены), но фраза «куплены все батчи» тут лжёт про работу, которой не было — я эту фразу у себя уже убрала. ⇒ у вас различитель тот же: `consolidated+declined+unanswered == 0 && never_asked > 0` ⇒ говорить «роль не спрашивали, банк уже рендерит всё», а не «всё куплено».
4. **Ремонт против РЕМОНТА — тот же класс, что строка 355, и он открыт.** Несколько ремонтных вызовов одного чанка сидят на одном `{глава, чанк, стадия}`, покупают непересекающиеся спаны и вытесняют друг друга в `superseded`. Замер выше: 77 % денег чанка. ⚠ Роль этот класс НЕ чинит: все спаны несут `role="repair"`, различает их только порядковый номер, которого в позиции нет. Рекомендую строку бэклога; лечение — не роль, а спан/ординал в ключе.
#### Чем предъявлено — команды и числа, снятые ПОСЛЕ последней правки кода
| Прибор | Команда | Вход (снят мной, не взят из промта) | Выход |
|---|---|---|---|
| Батарея | `make battery` (из `backend/`) | 19 `ok` · 0 FAIL · 4 «no test files» · 4 скипа поимённо | **то же самое**, `EXIT=0` (прогон ПОСЛЕ дофиксов) |
| Мутационный гейт | `make mutations` | 48 записей · 48 RED · 0 выживших · 0 unexpected · свип `0 of 259` | **65 записей · 65 RED · 0 выживших · 0 «NOTHING» · 0 unexpected · свип `0 of 276`**, `EXIT=0` |
| Формат | `gofmt -l .` | — | пусто |
| Линтер якорей доков | `python3 docs/scripts/counts.py --lint` | — | в `docs/PROGRESS.md` один проблемный якорь — строка **57**, шапка CURRENT-STATE, НЕ моя секция, и целит в `platform/internal/config/config.go`, который прямо сейчас правит параллельная сессия (случай «чужой WIP в цели», который линтер называет сам). Моих сломанных якорей — **0** |
| Тесты — ИМЕНАМИ, а не счётчиком | `git grep '^func Test' HEAD` против дерева, сведение через `comm` | 1285 имён в HEAD | **1302**: добавлено 17, удалено **0** |
| Каталог мутаций | `python3` по `mutations.json` | 259 записей, 48 в батарее | **276 записей, 65 в батарее** |
**Скипы поимённо (те же четыре, что на входе):** `TestMinerFullBookParity` · `TestCorpusBankKeyConflicts` · `TestHelperEventsRun` · `TestHelperKillLoop`.
**Семнадцать добавленных имён** (14 основного круга + 3 дофикса — `TestTheManifestSaysADeclaredTableOfContentsCouldNotBeRead`, `TestASidecarOlderThanTheFieldCannotAnswer`, `TestTheLogAndTheReadOutAgreeAboutWhatIsPartial`)**:** `TestABankCutByABudgetSaysSoInTheReadOut` · `TestABoundaryThatMeasuredNothingSaysNothing` · `TestAClassifierCutIsNotAnIncompleteBank` · `TestAWholeBankRaisesNoAlarm` · `TestEveryConsolidationFieldIsCarried` · `TestTheLedgerSaysTheClassifierBoughtTheBank` · `TestTheNeverAskedCountSurvivesTheStdoutCap` · `TestTheRoleSplitDoesNotMoveARepairsMoney` · `TestTheRunTotalIsThisRunsSpendIncludingTheClassifier` · `TestTheSignatureStopCarriesTheCompletenessToTheScreen` · `TestTheSigningBoundaryPublishesTheCompleteness` · `TestTheSigningScreenSaysHowCompleteTheBankIs` · `TestTwoBankRolesInOneBatchAreTwoPositions` · `TestEachConsolidationCounterComesFromItsOwnSource`.
**Почему все 17 моих записей — в батарейном подмножестве (рост 48 → 65, ~35 % времени гейта).** Каждая охраняет либо ДЕНЬГИ (число, по которому оператор решает, продолжать ли платить), либо ЛИСТ ПОДПИСИ (то, что владелец подписывает как закон для всей книги, `D39.104` п.1). Четырнадцать из семнадцати гоняют `./internal/pipeline/` (~16 с каждая без `-race`), три — `./cmd/tmctl/` (доли секунды). Держать их вне гейта значило бы, что следующая смена узнает о поломке от владельца, а не от батареи.
**Дельты нет: последний прогон стартовал ПОСЛЕ последней правки кода** — после него правился только этот отчёт. (В промежуточном круге дельта была — строка-литерал в одном `t.Fatalf`; я назвала её тогда вслух и закрыла этим прогоном, а не умолчанием.) `gofmt -l` пуст, застейдженного ноль.
**Деньги пака: $0.** Платных вызовов ноль. Всё предъявлено на фейк-провайдере (`newJSONProvider`) и собственных фикстурах; сырого леджера прогона 08.09 в дереве нет и я его не воспроизводила.
#### Что лендить — ПОЛНЫЙ список моих путей (21), pathspec-формой
⚠ **В дереве одновременно работает платформенная сессия — 22 позиции в `platform/`. Они НЕ мои, я их не трогала ни строкой и в этот список не включаю.**
Изменено (12): `backend/cmd/tmctl/render.go` · `backend/cmd/tmmutate/mutations.json` · `backend/internal/pipeline/bankexport.go` · `backend/internal/pipeline/bankfixpack_test.go` · `backend/internal/pipeline/mining.go` · `backend/internal/pipeline/paidtail.go` · `backend/internal/pipeline/runner.go` · `backend/internal/pipeline/stopsheetfindings_test.go` · `backend/internal/pipeline/terminologist.go` · `backend/internal/pipeline/waverun.go` · `backend/internal/store/ledger.go` · `docs/PROGRESS.md` (только моя секция).
Новое (4): `backend/cmd/tmctl/bankcompleteness_render_test.go` · `backend/internal/pipeline/bankcompleteness_test.go` · `backend/internal/pipeline/bankmoneyledger_test.go` · `backend/internal/pipeline/manifesttoc_test.go`.
Добавлено ДОФИКСОМ (ещё 5 к прежним 12): `backend/internal/pipeline/manifest.go` · `backend/internal/pipeline/bookrun.go` · `backend/internal/pipeline/contractblockers_test.go` · `backend/internal/pipeline/miningstop_join_test.go` · `backend/internal/pipeline/testdata/operator-messages.txt`.
**Диаграмм (`backend/docs/*.puml`) не трогала:** пак не менял ни состав компонентов, ни порядок стадий — только то, что каждая из них рассказывает наружу.
#### ДОФИКС ПРИЁМКИ (оркестратор, два пункта, правило остановки объявлено ДО работы)
**Дофикс 1 — §4.3 ВЗЯТ: заявленное нечитаемое оглавление доезжает до платформы.** Мой довод «манифест упирается в ключ» оркестратор снял прецедентом из того же файла, и он прав: `manifest.go` над `Price` и `Artifacts` прямо говорит, что для АДДИТИВНОГО поля версия **сознательно не двигается** — бамп выбросил бы каждый сохранённый сайдкар и пере-нарезал каждую книгу. ⇒ версию не трогала.
- `BookManifest.TOCUnreadable *int` рядом со `Structure`: `structure: delimited` теперь договаривает предложение — путь `declared` был испробован и провалился.
- ⛔ **Указатель, а не `int`, и это ровно цена аддитивности**, названная в том же файле: сайдкар СТАРШЕ поля проходит версию, ключ и `selfConsistent`, возвращается как «текущий» и поля не несёт — то есть **отсутствие есть ТРЕТЬЕ состояние, а не оттенок нуля**. Простой `int` отрисовал бы его как «ничего не заявлено, ничего не потеряно» — тот самый класс, который в этом файле уже стоил дефекта на `price` (приёмка V2-3).
- Предъявлено: `TestTheManifestSaysADeclaredTableOfContentsCouldNotBeRead` — НАСТОЯЩИЙ epub, чей навигационный документ объявлен манифестом и отсутствует в архиве, плюс вторая половина (книга без оглавления: ноль ПРИСУТСТВУЕТ в байтах) и `TestASidecarOlderThanTheFieldCannotAnswer` на три состояния.
**Дофикс 2 — ТРЕТЬЯ ЗАКАЗАННАЯ СМЕНА ПОВЕДЕНИЯ, и я объявляю её отдельным пунктом, потому что §4.4 говорил «две».** ⚠ Расхождение — заказ оркестратора, не моё решение: пинг 1 он взял и велел чинить.
- Было: одно предупреждение с условием `BatchesDropped > 0 || ClassifyBatchesDropped > 0` ⇒ на прогоне, где рендер-пас ЦЕЛ, а срезан только классификатор, лог кричал «this bank is PARTIALLY consolidated», а мой экран на том же прогоне говорил «WHOLE». Пара, спорящая сама с собой, хуже любой своей половины.
- Стало: два предупреждения на два разных бюджета. Про банк — только от среза РЕНДЕР-паса; срез классификатора говорит про ТИПЫ и прямо отрицает, что делает банк неполным.
- ⚠ **Правка голдена, вызванная заказанной сменой** (`D39.183`): `internal/pipeline/testdata/operator-messages.txt`**+2 строки, 1**. Голден не регенерируется; строки вставлены руками по тексту падения и файл пере-сортирован.
- Предъявлено: `TestTheLogAndTheReadOutAgreeAboutWhatIsPartial` — ТРИ фикстуры, и у каждого из двух условных сообщений закрыты ОБЕ половины: срезан рендер (банк-предупреждение обязано прозвучать) · срезан только классификатор (банк-предупреждение обязано МОЛЧАТЬ, типовое — прозвучать) · не срезано ничего (молчат оба). Понадобилась новая опция общей фикстуры `classifyBudgetUSD` — аддитивная, дефолт `1.0` не изменился.
**Правило остановки оркестратора соблюдено:** дофикса ровно два, третьего круга не открывала. Всё, что всплыло по ходу, ушло бы строкой — не всплыло ничего.
#### Уточнения к клеймам, которых потребовала приёмка
1. **Чем считаны тесты** (у приёмки вышло 1291→1305, у меня 1285→1299; дельта у обоих +14/0). Мой прибор: уникальные имена по `^func Test` в `backend --include=*_test.go`, сведённые `comm` против того же грепа по `HEAD`. Контроль тулчейном (снят на ФИНАЛЬНОМ дереве): `go test ./... -list '.*'` даёт **1299** без тегов и **1301** под `-tags live` — разница с моими 1302 объяснима до единицы: `-list` не показывает `TestMain` (он один), а два теста живут под тегом `live`. ⇒ числа разных приборов не «исправлять» друг другом; сходиться обязана ДЕЛЬТА.
2. **Чем снят ноль читателей класса A.** По GO-ИМЕНАМ (`Consolidated|Unanswered|BatchesDropped`) — 0 хитов при 183 `.go` в `platform/internal/`. По JSON-ИМЕНАМ (`consolidation|never_asked`) — 2 хита, и **оба прочитаны**: `ingest/manifest.go:97` и `pricing/pricing.go:112` — английская проза в комментариях про денежные проходы, а не чтение поля. ⚠ Ни один СЧЁТ на вопрос «есть ли читатель» не отвечает — отвечает только чтение хитов; счёт по подстроке в другую сторону дал бы ложное «читатели есть», и класс A выглядел бы закрытым, не будучи заведённым.
3. **`never_asked` — сверх списка §4.1, оставлено с санкции приёмки.** Довод: у экрана и артефакта ОДНА проекция, иначе завёлся бы второй носитель одного знания (`D39.216` п.3б). Секции предложений сайдкара (строка 353) это не касается — там пер-строчный флаг, здесь счётчик.
4. **Строгих декодеров на платформенной стороне ровно один**`internal/httpapi/bank.go:137`, и он на ДРУГОМ пути (тело клиентского запроса на правки банка). Мой артефакт разбирает `internal/ingest/bank.go` простым `json.Unmarshal`. То есть «`tm-bank-v1` не бампать» подтверждено с обеих сторон швa независимо.
#### Критерий завершённости (§13) — по пунктам, механически
| Пункт §13 | Состояние |
|---|---|
| у КАЖДОГО пункта §4 есть исход | да — таблица «Исход по КАЖДОМУ пункту §4» выше, пунктов без исхода нет |
| круги СОШЛИСЬ | да — круг 3 новых находок не дал; все находки кругов 13 закрыты таблицей «находка → что сделано → чем предъявлено» |
| каждый класс со ЗНАМЕНАТЕЛЕМ | да — таблица «закрыт в N из M», M посчитан командой, не на глаз |
| таблица мутаций полная, выжившие названы | да — 15 строк; выживший был ОДИН (мой, в круге 1), назван, причина разобрана, мутация переписана |
| числа сверены с ЛЕДЖЕРОМ | да — две ноги, первый прогон и резюм; формулировка инварианта исправлена после того, как ревьюер поймал в ней ложь |
| числа сняты ПОСЛЕ последней правки | да, с одной названной дельтой (строка-литерал в сообщении теста), разобранной выше |
| всё живое — в ДЕРЕВЕ | да — 15 путей, список выше; в письме не осталось ничего |
**Работа завершена, править не планирую.**
#### Вопросы оркестратору
1. **§4.3, выбор носителя.** Манифест — по адресу (`structure` там уже публикуется, читателя не надо изобретать), но `manifestVersion` ездит в КЛЮЧЕ манифеста, а его сдвиг мой пак запрещает. Считается ли аддитивное поле сменой формы, требующей бампа? Если нет — работа на полчаса, и я готова взять её дофиксом. Если да — это отдельное решение и отдельный пак.
2. **`never_asked` в машинном сайдкаре — сверх списка §4.1.** Я добавила его, чтобы у экрана и артефакта была ОДНА проекция и не завелось двух носителей одного знания. Секции предложений (строка 353) это не касается — там пер-строчный флаг, а тут счётчик. Оставляем?
3. **Ремонт и `superseded`** (пункт 2 в §10): заводить строку или это осознанная норма? Если норма — её стоит записать в шапку `paidtail.go`, потому что сегодня она там не сказана.
#### Пак «правда оператору и деньги» (08.09, промт `docs/BACKEND_OPERATOR_TRUTH_SESSION_PROMPT.md`, вход HEAD `060b13b`). НЕ КОММИЧУ — ждёт лендинга
**ЗАПИСКА-ПЛАН (§7), написана ДО первой правки и отправленная эхом оркестратору.** Порядок: 4.2 → 4.6 → 4.4 → 4.5 → 4.3 → 4.1 → прогон. Довод порядка: дешёвое и локальное вперёд, а единственная заказанная смена поведения (§4.1б) — последней, чтобы холодный прогон шёл по неподвижному коду; §4.7 запрещает менять код между двумя запусками из-за реплея чекпойнтов, и такой порядок делает запрет ненужным, а не соблюдаемым. Рискованным считала вырожденную фикстуру в §4.3 и §4.6 (на равных `Key`/`Src` мутация не краснеет) и то, что экономия §4.1 может оказаться артефактом обрезки бюджета, а не работой фильтра.