textmachine/platform/internal/ingest/bankreadout_test.go

392 lines
17 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 ingest
import (
"encoding/json"
"fmt"
"os"
"testing"
"gopkg.in/yaml.v3"
)
// bankreadout_test.go: the two sections a signing stop publishes beside the bank — engine backlog
// rows 224 and 253. Until this build the reader took `terms` alone, which at a stop is EMPTY by
// construction, so the one screen in the product that asks a person for a decision was served a zero
// that meant "the reader does not look here", not "there is nothing to decide".
const bankHead = `"bank_version":"tm-bank-v1","book_id":"bk_1","terms":[]`
// ⛔ THE PIN THIS PACK EXISTS FOR, one floor down from the screen: a section that is not in the
// document and a section that is there and holds nothing are different answers, and a reader that
// returns an empty slice for both has rebuilt the false zero it was written to remove. At a stop the
// first means "this read-out cannot tell you"; the second means "the stop asked nothing".
//
// Mutation this must catch: decoding `proposed` into a plain []Suggestion — json writes nil for both
// cases and every caller downstream then reports "no suggestions" for a document that never carried
// the section.
func TestASectionTheDocumentDoesNotCarryIsNotASectionThatIsEmpty(t *testing.T) {
absent, err := DecodeBank([]byte(`{` + bankHead + `}`))
if err != nil {
t.Fatal(err)
}
if absent.Offered != nil {
t.Errorf("a document with no `proposed` member reported a section: %+v", absent.Offered)
}
if absent.Consolidation != nil {
t.Errorf("a document with no `consolidation` member reported one: %+v", absent.Consolidation)
}
empty, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[],"consolidation":{}}`))
if err != nil {
t.Fatal(err)
}
if empty.Offered == nil {
t.Fatal("a `proposed` section that is present and empty was reported as absent")
}
if len(*empty.Offered) != 0 {
t.Errorf("an empty section decoded %d rows", len(*empty.Offered))
}
if empty.Consolidation == nil {
t.Fatal("a `consolidation` section that is present and empty was reported as absent")
}
// The premise the assertion above stands on, asserted so the fixture cannot lose its power to
// tell the two apart: an empty consolidation errs toward warning the signer, the opposite
// direction from the numbers below, and if that ever flips the test is measuring nothing.
if empty.Consolidation.Complete {
t.Error("an empty consolidation section claimed the bank is complete")
}
}
// ⛔ The measured case, not a hypothetical. `conventions` has no `omitempty` on the engine's side, so
// it is written ALWAYS — and it is absent from all 69 rows of one bought stop read-out and present on
// all 66 of the other, because the field was added to the projection between the two runs. An absent
// mandatory number therefore means "an engine build without this field wrote the document", and zero
// is a measurement: `freq: 0` is the engine's own "only the draft side saw it".
//
// Mutation this must catch: decoding any of the four numbers into a plain int.
func TestAMandatoryNumberTheReadOutDoesNotCarryIsNotZero(t *testing.T) {
doc := `{` + bankHead + `,"proposed":[{"src":"丙等","dst":"третий разряд","kind":"title","channel":"banknote","variants":["третий ранг ×1"]}]}`
bank, err := DecodeBank([]byte(doc))
if err != nil {
t.Fatal(err)
}
got := (*bank.Offered)[0]
for name, p := range map[string]*int{
"freq": got.Freq, "spread": got.Spread, "conventions": got.Conventions, "confidence": got.Confidence,
} {
if p != nil {
t.Errorf("%s was absent from the read-out and came back as %d, which is a figure nobody measured", name, *p)
}
}
stated := `{` + bankHead + `,"proposed":[{"src":"丙等","freq":0,"spread":0,"conventions":0,"conf":0}]}`
bank, err = DecodeBank([]byte(stated))
if err != nil {
t.Fatal(err)
}
got = (*bank.Offered)[0]
for name, p := range map[string]*int{
"freq": got.Freq, "spread": got.Spread, "conventions": got.Conventions, "confidence": got.Confidence,
} {
if p == nil {
t.Errorf("%s was stated as 0 in the read-out and came back as «not stated»", name)
} else if *p != 0 {
t.Errorf("%s was stated as 0 and came back as %d", name, *p)
}
}
}
// ⛔ THE ENGINE'S SENTINEL NEVER REACHES A CLIENT. It spells "the reply carried no confidence" as a
// NEGATIVE number, which is a sentinel and not a measurement; the canon promises `0..100` or null, so
// a build that carried the minus through would show a person "-1 %" on the one screen that asks them
// to decide. Folded at the SEAM, where every other crossing in this file is folded, because a
// translation left to a later path is one a later path can forget — and this one WAS forgotten once,
// between a canon that said `minimum: 0` and a projection that passed the value straight out.
//
// ⚠ A stated ZERO must survive: "the service said it was 0 % sure" is the first row a person should
// look at, and "the service said nothing" is not a row about confidence at all. The two are one byte
// apart in the engine's encoding and opposite in meaning.
//
// Mutation this must catch: returning `conf` unchanged from statedConfidence, or testing `<= 0`.
func TestTheEnginesNoConfidenceSentinelNeverReachesAClient(t *testing.T) {
bank, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[{"src":"a","conf":-1},{"src":"b","conf":0},{"src":"c"},{"src":"d","conf":95}]}`))
if err != nil {
t.Fatal(err)
}
rows := *bank.Offered
if rows[0].Confidence != nil {
t.Errorf("the engine's «no confidence stated» (-1) reached the wire as %d", *rows[0].Confidence)
}
if rows[1].Confidence == nil || *rows[1].Confidence != 0 {
t.Errorf("a stated confidence of 0 was folded into «not stated»: %v", rows[1].Confidence)
}
if rows[2].Confidence != nil {
t.Errorf("a row whose read-out carries no `conf` at all came back as %d", *rows[2].Confidence)
}
if rows[3].Confidence == nil || *rows[3].Confidence != 95 {
t.Errorf("a stated confidence of 95 came back as %v", rows[3].Confidence)
}
// The canon's own range, asserted over every row rather than over the one that motivated this:
// a value outside it is a document a generated client refuses.
for i, r := range rows {
if r.Confidence != nil && (*r.Confidence < 0 || *r.Confidence > 100) {
t.Errorf("row %d carries a confidence of %d, outside the canon's 0..100", i, *r.Confidence)
}
}
}
// `invented` is the class the engine names as the one to read FIRST — the consolidated rendering is
// not one any draft proposed. A reader that loses it shows a person a list in which nothing says the
// service made the name up. Measured: 11 of 69 rows in one bought read-out, 17 of 66 in the other.
func TestTheClassAPersonReadsFirstSurvivesTheReadOut(t *testing.T) {
bank, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[{"src":"a","invented":true},{"src":"b"}]}`))
if err != nil {
t.Fatal(err)
}
if !(*bank.Offered)[0].Invented {
t.Error("the engine said it invented this rendering and the reader dropped it")
}
if (*bank.Offered)[1].Invented {
t.Error("a row the engine did not mark came back marked")
}
}
// ⛔ A section is only ever read TOGETHER with the boundary that wrote it: the read-out is published
// at five of them and its own export warns three times over that the file may be a previous one's.
// "This document is not from a signing stop" and "the stop had nothing to ask" are different answers
// to a person staring at an empty screen.
//
// The engine's words are pipeline vocabulary and none passes through. Anything this build cannot name
// — including the empty string a document from before the freshness anchor carries — is the value
// that claims the LEAST, and the one thing it may never become is the stop.
func TestTheBoundaryIsTranslatedAndAnUnnameableOneIsNeverTheStop(t *testing.T) {
for engine, want := range map[string]string{
"run-start/seeded": BoundaryRunStarted,
"bank-mining/auto-continue": BoundaryTermsTakenUnsigned,
"bank-mining/signature-stop": BoundarySignatureRequested,
"run-finished": BoundaryRunFinished,
"redrive/re-seeded": BoundaryBookReseeded,
"bank-mining/some-boundary-added-later": BoundaryUnknown,
"": BoundaryUnknown,
} {
bank, err := DecodeBank([]byte(`{` + bankHead + `,"as_of":` + quote(engine) + `}`))
if err != nil {
t.Fatal(err)
}
if bank.Boundary != want {
t.Errorf("boundary %q read as %q, want %q", engine, bank.Boundary, want)
}
if engine != "bank-mining/signature-stop" && bank.Boundary == BoundarySignatureRequested {
t.Errorf("boundary %q was read as the signing stop", engine)
}
}
// The run identity is the anchor's other half and is read, not dropped: half an anchor is a
// freshness claim nothing supports.
bank, err := DecodeBank([]byte(`{` + bankHead + `,"run_id":"tm-stream-run_X-1"}`))
if err != nil {
t.Fatal(err)
}
if bank.RunID != "tm-stream-run_X-1" {
t.Errorf("the read-out's run identity came back as %q", bank.RunID)
}
}
// The detector's name is pipeline vocabulary — `mined` is the very word the contract renamed in 0.3.0
// so that it could not reach a client — so all four are translated and an unrecognised one becomes
// `unknown` rather than a guess. This axis is how a person judges how well corroborated a suggestion
// is, and inventing corroboration is the error reading further cannot undo.
func TestTheDetectorVocabularyDoesNotReachAClient(t *testing.T) {
for engine, want := range map[string]string{
"mined": ChannelSourceText, "banknote": ChannelTranslatedText,
"both": ChannelBoth, "alias": ChannelAlias, "surveyor": "", "": "",
} {
bank, err := DecodeBank([]byte(`{` + bankHead + `,"proposed":[{"src":"a","channel":` + quote(engine) + `}]}`))
if err != nil {
t.Fatal(err)
}
if got := (*bank.Offered)[0].Channel; got != want {
t.Errorf("channel %q read as %q, want %q", engine, got, want)
}
}
}
// The completeness numbers are COPIED and never re-derived: the engine computes `complete` off the
// render pass alone, and a classifier cut does not mean an incomplete bank. A second mechanism
// answering the same question on this side is how the two come to disagree — which is why the whole
// section is taken ready-made.
func TestTheCompletenessNumbersAreTakenReadyMade(t *testing.T) {
raw := `{"complete":false,"render_batches_dropped":5,"classify_batches_dropped":2,` +
`"consolidated":38,"declined":1,"unanswered":47,"never_asked":7}`
bank, err := DecodeBank([]byte(`{` + bankHead + `,"consolidation":` + raw + `}`))
if err != nil {
t.Fatal(err)
}
var want wireConsolidation
if err := json.Unmarshal([]byte(raw), &want); err != nil {
t.Fatal(err)
}
got := *bank.Consolidation
if got.Complete != want.Complete || got.RenderBatchesDropped != want.RenderBatchesDropped ||
got.ClassifyBatchesDropped != want.ClassifyBatchesDropped || got.Consolidated != want.Consolidated ||
got.Declined != want.Declined || got.Unanswered != want.Unanswered || got.NeverAsked != want.NeverAsked {
t.Errorf("the section was rewritten on the way in:\n got %+v\nwant %+v", got, want)
}
}
// The ORACLE: a read-out a real run actually published, handed in by the operator. The fixtures above
// are this build's own idea of the document; this one is the engine's, and the two bought stop
// read-outs it was written against differ from each other in exactly the way that matters — one
// carries `conventions` on every row and the other on none.
//
// Gated like every test that needs something this host may not have, and it SAYS what is missing.
func TestARealReadOutIsReadTheWayThisBuildClaims(t *testing.T) {
path := os.Getenv("TM_PLATFORM_TEST_BANK_READOUT")
if path == "" {
t.Skip("TM_PLATFORM_TEST_BANK_READOUT not set: the reader is not checked against a read-out a real run published")
}
raw, err := os.ReadFile(path)
if err != nil {
t.Fatal(err)
}
bank, err := DecodeBank(raw)
if err != nil {
t.Fatalf("a read-out a real run published was refused: %v", err)
}
// What the document itself says, read independently of the type under test — otherwise the
// assertion is the decoder agreeing with itself.
var doc map[string]json.RawMessage
if err := json.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
section, present := doc["proposed"]
if present != (bank.Offered != nil) {
t.Fatalf("the document %s a `proposed` member and the reader reported the section %s",
ifElse(present, "carries", "does not carry"), ifElse(bank.Offered != nil, "present", "absent"))
}
if present {
var rows []map[string]any
if err := json.Unmarshal(section, &rows); err != nil {
t.Fatal(err)
}
if len(rows) != len(*bank.Offered) {
t.Fatalf("the document carries %d suggestions and the reader returned %d", len(rows), len(*bank.Offered))
}
for i, row := range rows {
_, stated := row["conventions"]
if got := (*bank.Offered)[i].Conventions; stated != (got != nil) {
t.Fatalf("row %d: the document %s `conventions` and the reader reported it %s",
i, ifElse(stated, "states", "does not state"), ifElse(got != nil, "stated", "absent"))
}
if got := (*bank.Offered)[i].Channel; got == "" {
t.Errorf("row %d: this build cannot name the detector %q the engine wrote", i, row["channel"])
}
}
t.Logf("read-out %s: boundary=%s offered=%d terms=%d consolidation=%v",
path, bank.Boundary, len(*bank.Offered), len(bank.Terms), bank.Consolidation != nil)
}
if _, present := doc["consolidation"]; present != (bank.Consolidation != nil) {
t.Errorf("the document %s a `consolidation` member and the reader reported it %s",
ifElse(present, "carries", "does not carry"), ifElse(bank.Consolidation != nil, "present", "absent"))
}
if bank.Boundary == BoundaryUnknown {
t.Errorf("this build cannot name the boundary the engine wrote; the document's `as_of` is %s", doc["as_of"])
}
}
func quote(s string) string {
b, err := json.Marshal(s)
if err != nil {
panic(err)
}
return string(b)
}
func ifElse(cond bool, yes, no string) string {
if cond {
return yes
}
return no
}
// ⛔ EVERY RANGE THE CANON DECLARES IS ONE THIS READER ENFORCES — and the gate reads the canon rather
// than a copy of its numbers, so a field given a range tomorrow is covered without anybody
// remembering this test.
//
// It exists because the neighbouring gate proves MEMBERSHIP and stops there: it reads `required` and
// never `minimum`, which is the shape "take the form and not the guarantee" — in a gate. That gap let
// a canon promising `0..100` stand beside a chain that carried the engine's `-1` all the way out,
// pinned on both sides, for a whole delivery.
//
// Mutation this must catch: any numeric field whose canon range is not honoured by the decoder.
func TestEveryRangeTheCanonDeclaresIsOneThisReaderEnforces(t *testing.T) {
raw, err := os.ReadFile("../../../docs/architecture/14-api-contract/openapi.yaml")
if err != nil {
// Not skipped: a missing canon would leave these ranges with nothing to be checked against.
t.Fatalf("the ratified canon could not be read: %v", err)
}
var doc struct {
Components struct {
Schemas map[string]struct {
Properties map[string]struct {
Minimum *int `yaml:"minimum"`
Maximum *int `yaml:"maximum"`
} `yaml:"properties"`
} `yaml:"schemas"`
} `yaml:"components"`
}
if err := yaml.Unmarshal(raw, &doc); err != nil {
t.Fatal(err)
}
// The engine's field names, against the contract's — the reader is the thing that maps between
// them, so a gate over both has to carry the pair.
engineName := map[string]string{"freq": "freq", "spread": "spread",
"conventions": "conventions", "confidence": "conf"}
ranged := 0
for member, spec := range doc.Components.Schemas["OfferedTerm"].Properties {
if spec.Minimum == nil && spec.Maximum == nil {
continue
}
ranged++
engine, ok := engineName[member]
if !ok {
t.Errorf("the canon gives `%s` a range and this gate does not know which engine field feeds it", member)
continue
}
for _, outside := range outsideOf(spec.Minimum, spec.Maximum) {
bank, err := DecodeBank([]byte(fmt.Sprintf(`{%s,"proposed":[{"src":"x","%s":%d}]}`,
bankHead, engine, outside)))
if err != nil {
t.Fatal(err)
}
got := map[string]*int{
"freq": (*bank.Offered)[0].Freq, "spread": (*bank.Offered)[0].Spread,
"conventions": (*bank.Offered)[0].Conventions, "confidence": (*bank.Offered)[0].Confidence,
}[member]
if got == nil {
continue // read as "not stated", which is inside the contract
}
if (spec.Minimum != nil && *got < *spec.Minimum) || (spec.Maximum != nil && *got > *spec.Maximum) {
t.Errorf("the canon bounds `%s` and the reader passed %d straight through from the engine's %d",
member, *got, outside)
}
}
}
// The gate's own denominator: a canon whose ranges this gate silently found none of would pass
// while proving nothing.
if ranged == 0 {
t.Fatal("the canon's OfferedTerm declares no ranges at all: this gate is reading the wrong schema")
}
t.Logf("ranges read from the canon and exercised: %d", ranged)
}
// outsideOf returns values just outside a declared range — one below the floor, one above the ceiling.
func outsideOf(min, max *int) []int {
var out []int
if min != nil {
out = append(out, *min-1)
}
if max != nil {
out = append(out, *max+1)
}
return out
}