Files
sanderling/cmd/internal-tools/analyze/paired_test.go
T
pj 1008402558 fix(analyze): score a seed pair by which run outlived the other
The paired path had the same defect as the unpaired one: it subtracted two
step counts and handed the differences to the signed-rank test, so a pair
holding a run the wall clock stopped at step 12 entered as a difference
neither run supports. Twenty seeds where the first arm was still clean at
step 12 and the second violated at step 5 in six of them read sign -1 and
p 0.0011, pointing at the arm that never violated.

A pair is now scored the way the unpaired comparison scores one and tested by
the exact sign test over the pairs whose order censoring determines, which is
what the log-rank stratified by seed reduces to here. The signed-rank goes
with the differences it needed: a magnitude-based paired test wants a
difference from every pair, and the arms censor on different clocks. The
median difference stays, over the pairs where both runs violated, and says so.
2026-08-18 20:16:40 +05:30

191 lines
6.9 KiB
Go

package main
import (
"math"
"testing"
)
// The two-sided sign test is R's binom.test(k, n) at p = 0.5, which is the
// doubled tail of a symmetric binomial and can be worked out by hand from the
// coefficients: 2 * sum(C(n, i), i <= min(k, n-k)) / 2^n.
func TestSignTest_MatchesTheBinomialTail(t *testing.T) {
cases := []struct {
first, second int
want float64
}{
{0, 10, 2.0 / 1024},
{1, 9, 2 * 11.0 / 1024},
{3, 7, 2 * 176.0 / 1024},
{5, 5, 1},
{0, 1, 1},
{2, 0, 0.5},
}
for _, test := range cases {
got := signTest(test.first, test.second)
if math.Abs(got-test.want) > 1e-12 {
t.Errorf("sign test on %d against %d gives %v, want %v", test.first, test.second, got, test.want)
}
if reversed := signTest(test.second, test.first); math.Abs(reversed-got) > 1e-12 {
t.Errorf("sign test on %d against %d gives %v reversed and %v forward",
test.first, test.second, reversed, got)
}
}
}
// A campaign runs tens of seeds, not tens of thousands, but the tail is summed
// through log-gamma rather than through factorials so that a lopsided family
// stays a number rather than becoming an overflow.
func TestSignTest_LargeCountsStayFinite(t *testing.T) {
if got := signTest(0, 200); got <= 0 || got > 1e-59 {
t.Errorf("sign test on 0 against 200 gives %v, want a positive value around 2^-199", got)
}
if got := signTest(100, 100); math.Abs(got-1) > 1e-12 {
t.Errorf("sign test on an even split gives %v, want 1", got)
}
}
func TestSignTest_NoOrderedPairHasNoTest(t *testing.T) {
if got := signTest(0, 0); !math.IsNaN(got) {
t.Errorf("sign test with nothing to test gives %v, want undefined", got)
}
}
func TestPairArms_ScoresEachPairByWhichRunOutlivedTheOther(t *testing.T) {
pre := arm{Name: "pre", Budget: 40, Runs: []classifiedRun{
violatingRun(1, 30, 30, "doubleTapCharges"),
cleanRun(2, 40),
violatingRun(3, 25, 25, "doubleTapCharges"),
}}
post := arm{Name: "post", Budget: 40, Runs: []classifiedRun{
violatingRun(1, 10, 10, "doubleTapCharges"),
violatingRun(2, 12, 12, "doubleTapCharges"),
violatingRun(3, 25, 25, "doubleTapCharges"),
}}
comparison, err := pairArms(pre, post)
if err != nil {
t.Fatal(err)
}
if comparison.Pairs != 3 {
t.Fatalf("%d pairs, want 3", comparison.Pairs)
}
// Seed 1 violated at 30 against 10 and seed 2 was still clean at 40 when its
// partner violated at 12, so both go to the second arm; seed 3 violated on
// the same step in both and has no order.
if comparison.SecondSooner != 2 || comparison.FirstSooner != 0 || comparison.Unordered != 1 {
t.Errorf("counts %+v, want two favouring the second arm and one unordered", comparison)
}
if comparison.Sign != 1 {
t.Errorf("sign %d, want +1 for the arm that violated later", comparison.Sign)
}
if math.Abs(comparison.A12-2.5/3) > 1e-12 {
t.Errorf("a12 within pairs %v, want %v", comparison.A12, 2.5/3)
}
// Only seeds 1 and 3 have a difference in steps to take a median of, 20 and
// 0: the pair holding a clean run has no difference either arm supports.
if comparison.BothViolated != 2 || comparison.MedianDifference == nil || *comparison.MedianDifference != 10 {
t.Errorf("median difference %v over %d pair(s), want 10 over 2",
comparison.MedianDifference, comparison.BothViolated)
}
if want := signTest(0, 2); comparison.PValue != want {
t.Errorf("p %v, want the sign test's %v over the two ordered pairs", comparison.PValue, want)
}
}
// Two clean runs are two runs that were still going when they stopped, whatever
// step each stopped on, so the pair says nothing and is not a trial.
func TestPairArms_PairsOfCleanRunsAreNotEvidence(t *testing.T) {
early := arm{Name: "early", Budget: 400, Runs: []classifiedRun{cleanRun(1, 12), cleanRun(2, 14)}}
late := arm{Name: "late", Budget: 400, Runs: []classifiedRun{cleanRun(1, 400), cleanRun(2, 380)}}
comparison, err := pairArms(early, late)
if err != nil {
t.Fatal(err)
}
if comparison.Unordered != 2 || comparison.Sign != 0 {
t.Errorf("comparison %+v, want both pairs unordered and no direction", comparison)
}
if !math.IsNaN(comparison.PValue) {
t.Errorf("p %v, want undefined with no ordered pair", comparison.PValue)
}
if comparison.MedianDifference != nil {
t.Errorf("median difference %v, want undefined where no pair has two violations",
*comparison.MedianDifference)
}
if comparison.A12 != 0.5 {
t.Errorf("a12 within pairs %v, want 0.5", comparison.A12)
}
}
// A run excluded as missing data cannot be paired against anything, and the
// seed it came from has to be named rather than silently shrinking the sample.
func TestPairArms_NamesSeedsUsableInOneArmOnly(t *testing.T) {
pre := arm{Name: "pre", Budget: 40, Runs: []classifiedRun{
violatingRun(1, 30, 30, "p"),
{Seed: 2, ExcludedBecause: reasonTimedOut},
violatingRun(3, 20, 20, "p"),
}}
post := arm{Name: "post", Budget: 40, Runs: []classifiedRun{
violatingRun(1, 10, 10, "p"),
violatingRun(2, 11, 11, "p"),
}}
comparison, err := pairArms(pre, post)
if err != nil {
t.Fatal(err)
}
if comparison.Pairs != 1 {
t.Fatalf("%d pairs, want 1", comparison.Pairs)
}
if len(comparison.UnpairedSeeds) != 2 || comparison.UnpairedSeeds[0] != 2 || comparison.UnpairedSeeds[1] != 3 {
t.Errorf("unpaired seeds %v, want [2 3]", comparison.UnpairedSeeds)
}
}
func TestPairArms_RefusesTwoUsableRunsForOneSeed(t *testing.T) {
pooled := arm{Name: "pre", Budget: 40, Runs: []classifiedRun{
violatingRun(1, 30, 30, "p"),
violatingRun(1, 12, 12, "p"),
}}
post := arm{Name: "post", Budget: 40, Runs: []classifiedRun{violatingRun(1, 10, 10, "p")}}
if _, err := pairArms(pooled, post); err == nil {
t.Fatal("paired two arms where one seed ran twice")
}
}
// The paired comparison is the ablation's decision rule, so the direction it
// reports has to survive the arms being passed the other way round.
func TestPairArms_DirectionReversesWithTheArms(t *testing.T) {
pre := arm{Name: "pre", Budget: 400, Runs: []classifiedRun{
cleanRun(1, 400), cleanRun(2, 400), violatingRun(3, 380, 380, "p"),
cleanRun(4, 400), violatingRun(5, 350, 350, "p"),
}}
post := arm{Name: "post", Budget: 400, Runs: []classifiedRun{
violatingRun(1, 40, 40, "p"), violatingRun(2, 90, 90, "p"), violatingRun(3, 60, 60, "p"),
violatingRun(4, 120, 120, "p"), violatingRun(5, 30, 30, "p"),
}}
forward, err := pairArms(pre, post)
if err != nil {
t.Fatal(err)
}
reversed, err := pairArms(post, pre)
if err != nil {
t.Fatal(err)
}
if forward.Sign != 1 || reversed.Sign != -1 {
t.Errorf("signs %+d and %+d, want +1 then -1", forward.Sign, reversed.Sign)
}
if *forward.MedianDifference != -*reversed.MedianDifference {
t.Errorf("median differences %v and %v, want opposites",
*forward.MedianDifference, *reversed.MedianDifference)
}
if math.Abs(forward.PValue-reversed.PValue) > 1e-12 {
t.Errorf("p-values %v and %v, want the same two-sided value", forward.PValue, reversed.PValue)
}
if math.Abs(forward.A12+reversed.A12-1) > 1e-12 {
t.Errorf("a12 %v and %v, want them to sum to 1", forward.A12, reversed.A12)
}
}