170 lines
6.8 KiB
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
|
|
}
|