textmachine/backend/internal/pipeline/bankbatchordinal_probe_test.go

170 lines
6.8 KiB
Go

package pipeline
import (
"fmt"
"math/rand"
"os"
"testing"
"textmachine/backend/internal/terminology"
)
// bankbatchordinal_probe_test.go: an opt-in INSTRUMENT, not a unit test — the measurement backlog row 478
// was re-opened by.
//
// ⛔ WHY IT IS IN THE TREE AT ALL. Row 478 said: let the already-settled filter drop one row and «the
// ordinals of every later batch shift, so they are bought again at unchanged text». A bank batch is
// addressed by the hash of its request and the batch ordinal is a field of that hash, so if the claim held
// it would be money. It does not hold, and the refusal to re-key the money path rests on THIS measurement —
// which means the measurement has to be re-runnable by whoever doubts it, not quoted from a report.
//
// WHAT IT MEASURES. Batching is greedy over a rune budget. Removing a candidate from batch k either pulls
// content forward from k+1 — and then every later batch differs in TEXT, so its ordinal is irrelevant —
// or it does not, and then nothing changes at all. A batch keeping byte-identical content at a DIFFERENT
// ordinal needs a whole batch to vanish from the middle, which one removal cannot do.
//
// WHAT IT FOUND (run it to reproduce):
//
// - one candidate removed, 186 removals over three regimes: ZERO batches same-content-shifted, against a
// live 239…630 content-changed — so the instrument does separate the classes;
// - a BLOCK removed (which is how the filter really works — it drops a set), 200 random blocks per
// regime: 490 shifts at batch_runes=400, and ZERO at the shipping 6000, on both 66 and 300 candidates;
// - positive control, a whole batch removed: 18 shifts, so the zeros above are a measurement and not a
// blind instrument.
//
// ⚠ The numbers above are what THIS file prints, on its own fixed seed. An earlier throwaway of the same
// measurement reported 475 for the small-batch regime rather than 490: same conclusion, a different draw
// order. A comment quoting figures its own code does not produce is a comment that rots on first reading.
//
// ⚠ AND THE CONDITION UNDER WHICH THE CONCLUSION STOPS HOLDING, because a conclusion without one is how a
// later edit removes the premise without noticing: the class is LATENT, not absent. It needs many small
// batches. No shipping pipeline sets batch_runes, so the engine default of 6000 is what runs — on the cold
// run that was 3 batches for 66 terms. Set batch_runes to a few hundred and the class is live at ~15%.
func TestProbeBankBatchOrdinalShift(t *testing.T) {
if os.Getenv("TM_PROBE_478") == "" {
t.Skip("instrument: set TM_PROBE_478=1 to re-measure the batch-ordinal shift of backlog row 478")
}
for _, scen := range []struct {
name string
n int
sizes []int
runes int
blocks bool
}{
{"one-removed/mixed/400", 60, []int{5, 40, 12, 80, 20}, 400, false},
{"one-removed/uniform/400", 60, []int{40}, 400, false},
{"one-removed/shipping-6000/n=66", 66, []int{5, 40, 12, 80, 20}, 6000, false},
{"block-removed/mixed/400", 60, []int{5, 40, 12, 80, 20}, 400, true},
{"block-removed/shipping-6000/n=66", 66, []int{5, 40, 12, 80, 20}, 6000, true},
{"block-removed/shipping-6000/n=300", 300, []int{5, 40, 12, 80, 20}, 6000, true},
} {
base := terminology.Batch(probeOrdinalCands(scen.n, scen.sizes), scen.runes, nil)
rng := rand.New(rand.NewSource(1))
var untouched, changed, shifted, trials int
removals := func() []map[int]bool {
var out []map[int]bool
if !scen.blocks {
for i := 0; i < scen.n; i++ {
out = append(out, map[int]bool{i: true})
}
return out
}
for i := 0; i < 200; i++ {
drop := map[int]bool{}
for len(drop) < 1+rng.Intn(scen.n/3) {
drop[rng.Intn(scen.n)] = true
}
out = append(out, drop)
}
return out
}()
for _, drop := range removals {
trials++
var kept []terminology.Candidate
for i, c := range probeOrdinalCands(scen.n, scen.sizes) {
if !drop[i] {
kept = append(kept, c)
}
}
u, ch, sh := probeOrdinalClassify(base, terminology.Batch(kept, scen.runes, nil))
untouched, changed, shifted = untouched+u, changed+ch, shifted+sh
}
// The denominator is printed beside the answer: «0 shifted» and «the instrument saw nothing» are
// the same line otherwise.
t.Logf("%-32s batches=%2d trials=%3d | untouched=%4d content-changed=%4d SAME-CONTENT-SHIFTED=%d",
scen.name, len(base), trials, untouched, changed, shifted)
}
// POSITIVE CONTROL. Remove a whole batch from the middle and the shift must appear — without this the
// zeros above would be indistinguishable from an instrument that cannot see the class at all.
const n, runes = 60, 400
base := terminology.Batch(probeOrdinalCands(n, []int{40}), runes, nil)
if len(base) < 3 {
t.Fatalf("premise broken: the control needs a middle batch to remove, got %d batches", len(base))
}
drop := map[string]bool{}
for _, c := range base[1] {
drop[c.Key] = true
}
var kept []terminology.Candidate
for _, c := range probeOrdinalCands(n, []int{40}) {
if !drop[c.Key] {
kept = append(kept, c)
}
}
_, _, sh := probeOrdinalClassify(base, terminology.Batch(kept, runes, nil))
t.Logf("POSITIVE CONTROL (whole middle batch removed): SAME-CONTENT-SHIFTED=%d", sh)
if sh == 0 {
t.Fatal("the instrument reports ZERO shifts even when a whole batch is removed from the middle — " +
"it cannot see the class, so every zero it printed above is about the instrument and not about " +
"the batcher")
}
}
// probeOrdinalCands builds a candidate list whose rendered sizes cycle through `sizes`. Only the SIZE
// matters to the batcher, which is why the content is filler.
func probeOrdinalCands(n int, sizes []int) []terminology.Candidate {
out := make([]terminology.Candidate, 0, n)
for i := 0; i < n; i++ {
body := make([]rune, sizes[i%len(sizes)])
for j := range body {
body[j] = '字'
}
out = append(out, terminology.Candidate{
Key: fmt.Sprintf("k%03d", i), Src: fmt.Sprintf("k%03d", i), Type: "term",
Variants: []terminology.Variant{{Dst: string(body)}},
})
}
return out
}
// probeOrdinalClassify sorts every batch of the NEW packing into one of three: identical bytes at the same
// ordinal (nothing was re-bought), different bytes (re-bought because the REQUEST differs — which no
// change to the ordinal axis could prevent), or identical bytes at a different ordinal — the only class
// row 478 is about.
func probeOrdinalClassify(base, got [][]terminology.Candidate) (untouched, changed, shifted int) {
baseTxt := make([]string, len(base))
for i, b := range base {
baseTxt[i] = terminology.RenderBatch(b)
}
for i, b := range got {
txt := terminology.RenderBatch(b)
if i < len(baseTxt) && txt == baseTxt[i] {
untouched++
continue
}
moved := false
for j, bt := range baseTxt {
if bt == txt && j != i {
moved = true
break
}
}
if moved {
shifted++
} else {
changed++
}
}
return
}