textmachine/backend/internal/pipeline/bankcompleteness_test.go

374 lines
19 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
BasisServed: 7,
})
whole := projectBankConsolidation(&terminologyResult{
ClassifyBatchesDropped: 2, Consolidated: 3, Declined: 4, Unanswered: 5, BankSettled: 6, BasisServed: 7,
})
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)
}
}