Files
sanderling/cmd/internal-tools/analyze/planted_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

677 lines
26 KiB
Go

package main
import (
"encoding/json"
"fmt"
"io"
"math"
"math/rand"
"os"
"path/filepath"
"testing"
)
// A pipeline exercised only on data whose answer nobody knows reports that it
// runs, not that it is right. These tests plant effects whose value is known in
// advance from the model that generated the data, and require the pipeline to
// recover them from campaign directories it reads off disk through its own
// entry point.
//
// plantedModel is the generator: a run that has not yet violated does so at
// every step with probability Hazard, and a run reaching Budget without
// violating is right-censored there. Steps to first violation is therefore
// geometric, truncated at the budget, and every quantity the pipeline reports
// about it has a closed form below.
type plantedModel struct {
Hazard float64
Budget int
}
// recordedValues is the distribution of what one run contributes to the
// analysis: the violation step when it violates, and the budget when it does
// not, since a censored run is held at the budget. Index t carries P(value = t).
func (model plantedModel) recordedValues() []float64 {
probabilities := make([]float64, model.Budget+1)
survival := 1.0
for step := 1; step < model.Budget; step++ {
probabilities[step] = survival * model.Hazard
survival *= 1 - model.Hazard
}
probabilities[model.Budget] = survival
return probabilities
}
func (model plantedModel) survivalAt(step int) float64 {
return math.Pow(1-model.Hazard, float64(step))
}
func (model plantedModel) censoredShare() float64 {
return model.survivalAt(model.Budget)
}
// medianSteps is the smallest step at which the true survival function falls to
// or below one half, which is what the Kaplan-Meier median estimates.
func (model plantedModel) medianSteps() (int, bool) {
for step := 1; step <= model.Budget; step++ {
if model.survivalAt(step) <= 0.5 {
return step, true
}
}
return 0, false
}
// populationA12 is the Vargha-Delaney effect size between two planted models,
// computed from the models rather than from any sample: the probability that a
// run of the first takes more steps than a run of the second, counting a tie as
// half. Every run held at the budget ties with every other, which is the term a
// pipeline mishandling censoring gets wrong.
func populationA12(first, second plantedModel) float64 {
left, right := first.recordedValues(), second.recordedValues()
total := 0.0
for value, probability := range left {
if probability == 0 {
continue
}
for other, otherProbability := range right {
switch {
case value > other:
total += probability * otherProbability
case value == other:
total += 0.5 * probability * otherProbability
}
}
}
return total
}
type plantedRun struct {
seed int64
steps int
violated bool
}
func (model plantedModel) draw(seed int64, source *rand.Rand) plantedRun {
for step := 1; step <= model.Budget; step++ {
if source.Float64() < model.Hazard {
return plantedRun{seed: seed, steps: step, violated: true}
}
}
return plantedRun{seed: seed, steps: model.Budget}
}
func (model plantedModel) drawCampaign(source *rand.Rand, seeds int) []plantedRun {
runs := make([]plantedRun, 0, seeds)
for seed := int64(1); seed <= int64(seeds); seed++ {
runs = append(runs, model.draw(seed, source))
}
return runs
}
// writePlantedCampaign emits the campaign directory shape the campaign tool
// writes, so the planted data reaches the statistics through the same loader,
// classifier and censoring rules as a real sweep.
func writePlantedCampaign(t *testing.T, directory, armName string, budget int, runs []plantedRun) string {
t.Helper()
seeds := make([]int64, 0, len(runs))
records := make([]map[string]any, 0, len(runs))
for _, run := range runs {
seeds = append(seeds, run.seed)
record := map[string]any{
"seed": run.seed,
"exit_code": 0,
"steps": run.steps,
"actions": run.steps,
"monotonic_millis": int64(run.steps) * 900,
}
if run.violated {
record["first_violation_origin_step"] = run.steps
record["violated_properties"] = []string{"plantedProperty"}
} else {
record["first_violation_origin_step"] = nil
}
records = append(records, record)
}
writeCampaign(t, directory, map[string]any{
"arm": armName,
"generator": "seeded",
"platform": "android",
"max_steps": budget,
"seeds": seeds,
"host": "planted",
}, records)
return directory
}
// analyseCampaigns runs the tool exactly as the command line does and returns
// the machine-readable summary it wrote.
func analyseCampaigns(t *testing.T, arguments ...string) analysis {
t.Helper()
summaryPath := filepath.Join(t.TempDir(), "analysis.json")
if err := run(append([]string{"--json", summaryPath}, arguments...), io.Discard, io.Discard); err != nil {
t.Fatal(err)
}
body, err := os.ReadFile(summaryPath)
if err != nil {
t.Fatal(err)
}
var result analysis
if err := json.Unmarshal(body, &result); err != nil {
t.Fatal(err)
}
return result
}
func armByName(t *testing.T, result analysis, name string) armSummary {
t.Helper()
for _, summary := range result.Arms {
if summary.Arm == name {
return summary
}
}
t.Fatalf("no arm %q in %v", name, result.Arms)
return armSummary{}
}
// plantTwoArms writes two campaign directories drawn from the given models and
// returns them in the order they were written.
func plantTwoArms(t *testing.T, sourceSeed int64, seeds int, first, second plantedModel) (string, string) {
t.Helper()
root := t.TempDir()
source := rand.New(rand.NewSource(sourceSeed))
firstDirectory := writePlantedCampaign(t, filepath.Join(root, "first"), "first", first.Budget,
first.drawCampaign(source, seeds))
secondDirectory := writePlantedCampaign(t, filepath.Join(root, "second"), "second", second.Budget,
second.drawCampaign(source, seeds))
return firstDirectory, secondDirectory
}
// The effect size is the number the paper reports as the size of a difference,
// so it is checked against the value the generating models define rather than
// against anything this tool produced. Four independent draws are used because
// a tolerance that holds for one lucky sample is not a check.
func TestPlanted_RecoversTheKnownEffectSize(t *testing.T) {
slow := plantedModel{Hazard: 0.005, Budget: 400}
fast := plantedModel{Hazard: 0.02, Budget: 400}
expected := populationA12(slow, fast)
if expected < 0.6 {
t.Fatalf("planted effect %v is too small to be worth checking", expected)
}
for _, sourceSeed := range []int64{1, 2, 3, 4} {
slowDirectory, fastDirectory := plantTwoArms(t, sourceSeed, 400, slow, fast)
result := analyseCampaigns(t, slowDirectory, fastDirectory)
if len(result.Pairwise) != 1 {
t.Fatalf("%d comparisons, want 1", len(result.Pairwise))
}
pair := result.Pairwise[0]
if pair.First != "first" || pair.Second != "second" {
t.Fatalf("comparison %s vs %s, want first vs second", pair.First, pair.Second)
}
if math.Abs(pair.A12-expected) > 0.03 {
t.Errorf("seed %d: a12 %.4f, want the planted %.4f", sourceSeed, pair.A12, expected)
}
if pair.PValue > 1e-6 {
t.Errorf("seed %d: p-value %v for an effect this large", sourceSeed, pair.PValue)
}
}
}
// A12 above one half has to mean the first arm took longer, and a sign flip
// anywhere in the pipeline is the failure that produces a plausible wrong
// answer rather than an obvious one.
func TestPlanted_EffectSizeDirectionFollowsTheArmOrder(t *testing.T) {
slow := plantedModel{Hazard: 0.005, Budget: 400}
fast := plantedModel{Hazard: 0.02, Budget: 400}
source := rand.New(rand.NewSource(7))
slowRuns := slow.drawCampaign(source, 300)
fastRuns := fast.drawCampaign(source, 300)
// Comparisons are ordered by arm label, so the same two samples are
// analysed twice with the labels swapped.
root := t.TempDir()
forward := analyseCampaigns(t,
writePlantedCampaign(t, filepath.Join(root, "slow-first"), "a", slow.Budget, slowRuns),
writePlantedCampaign(t, filepath.Join(root, "fast-second"), "b", fast.Budget, fastRuns)).Pairwise[0]
reversed := analyseCampaigns(t,
writePlantedCampaign(t, filepath.Join(root, "slow-second"), "b", slow.Budget, slowRuns),
writePlantedCampaign(t, filepath.Join(root, "fast-first"), "a", fast.Budget, fastRuns)).Pairwise[0]
if forward.A12 <= 0.5 {
t.Errorf("a12 %.4f with the slower arm first, want above 0.5", forward.A12)
}
if math.Abs(forward.A12+reversed.A12-1) > 1e-12 {
t.Errorf("a12 %.4f and %.4f, want them to sum to 1", forward.A12, reversed.A12)
}
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)
}
}
// The median and the quartiles are read off the Kaplan-Meier curve, and the
// curve itself is checked point by point against the survival function that
// generated the data. A pipeline counting a censored run as a violation drives
// this estimate down hard.
func TestPlanted_SurvivalCurveTracksTheGeneratingHazard(t *testing.T) {
model := plantedModel{Hazard: 0.005, Budget: 400}
if model.censoredShare() < 0.1 {
t.Fatalf("planted censoring is %.3f, too little to exercise the estimator", model.censoredShare())
}
directory, _ := plantTwoArms(t, 11, 600, model, model)
summary := armByName(t, analyseCampaigns(t, directory), "first")
for _, step := range []int{20, 50, 100, 200, 300} {
estimated, ok := survivalAtStep(summary.SurvivalCurve, float64(step))
if !ok {
t.Fatalf("curve has no point at or before step %d", step)
}
if want := model.survivalAt(step); math.Abs(estimated-want) > 0.05 {
t.Errorf("survival at %d is %.4f, want the planted %.4f", step, estimated, want)
}
}
median, ok := model.medianSteps()
if !ok {
t.Fatal("the planted model has no median to recover")
}
if summary.MedianStepsToFirstViolation == nil {
t.Fatal("median undefined, want the planted median")
}
if math.Abs(*summary.MedianStepsToFirstViolation-float64(median)) > 10 {
t.Errorf("median %v, want the planted %d", *summary.MedianStepsToFirstViolation, median)
}
assertQuartile(t, summary.FirstQuartileSteps, model, 0.25)
assertQuartile(t, summary.ThirdQuartileSteps, model, 0.75)
}
func assertQuartile(t *testing.T, got *float64, model plantedModel, fraction float64) {
t.Helper()
if got == nil {
t.Fatalf("quantile %v undefined, want a value", fraction)
}
if want := model.survivalAt(int(*got)); want > 1-fraction+0.05 {
t.Errorf("quantile %v at step %v, where the planted survival is %.4f", fraction, *got, want)
}
}
func survivalAtStep(curve []survivalPoint, step float64) (float64, bool) {
value, found := 0.0, false
for _, point := range curve {
if point.Steps > step {
break
}
value, found = point.Survival, true
}
return value, found
}
// A true null must not be called significant more often than the test's own
// level allows. This is the check that catches a wrong variance term, which
// leaves every point estimate looking reasonable and only shows up in how often
// the pipeline claims a difference that is not there.
func TestPlanted_TrueNullIsNotCalledSignificantAboveItsLevel(t *testing.T) {
const replicates = 300
model := plantedModel{Hazard: 0.01, Budget: 400}
rejectedByRankSum, rejectedByLogRank := 0, 0
for replicate := 0; replicate < replicates; replicate++ {
first, second := plantTwoArms(t, int64(1000+replicate), 30, model, model)
result := analyseCampaigns(t, first, second)
if result.Pairwise[0].PValue < 0.05 {
rejectedByRankSum++
}
if result.LogRank.PValue < 0.05 {
rejectedByLogRank++
}
}
// Three standard errors around 0.05 at 300 replicates is 0.05 +/- 0.038.
// The lower bound is asserted too: a test that never rejects has bought its
// level by losing the power the experiment is sized for.
for name, rejected := range map[string]int{"rank-sum": rejectedByRankSum, "log-rank": rejectedByLogRank} {
rate := float64(rejected) / replicates
if rate > 0.09 || rate < 0.015 {
t.Errorf("%s called a true null significant in %.1f%% of %d replicates, want about 5%%",
name, 100*rate, replicates)
}
}
}
// The same null with most runs censored, where nearly every observation is tied
// at the budget and the tie correction is what keeps the level honest.
func TestPlanted_TrueNullUnderHeavyCensoringKeepsItsLevel(t *testing.T) {
const replicates = 300
model := plantedModel{Hazard: 0.0004, Budget: 400}
if model.censoredShare() < 0.8 {
t.Fatalf("planted censoring is %.3f, want most runs censored", model.censoredShare())
}
rejected := 0
for replicate := 0; replicate < replicates; replicate++ {
first, second := plantTwoArms(t, int64(9000+replicate), 40, model, model)
if analyseCampaigns(t, first, second).Pairwise[0].PValue < 0.05 {
rejected++
}
}
if rate := float64(rejected) / replicates; rate > 0.09 {
t.Errorf("rank-sum called a true null significant in %.1f%% of %d replicates under heavy censoring, want at most about 5%%",
100*rate, replicates)
}
}
// Most runs never violating is the expected shape of an arm at a budget chosen
// for a harder subject, and it is the case where an implementation that drops
// censored runs instead of holding them at the budget still produces a number.
func TestPlanted_HeavyCensoringLeavesTheMedianUndefinedAndKeepsTheEffect(t *testing.T) {
quiet := plantedModel{Hazard: 0.00025, Budget: 400}
loud := plantedModel{Hazard: 0.002, Budget: 400}
if quiet.censoredShare() < 0.85 {
t.Fatalf("planted censoring is %.3f, want most runs censored", quiet.censoredShare())
}
if _, ok := quiet.medianSteps(); ok {
t.Fatal("the heavily censored model has a median, so the test is not testing what it says")
}
quietDirectory, loudDirectory := plantTwoArms(t, 21, 400, quiet, loud)
result := analyseCampaigns(t, quietDirectory, loudDirectory)
summary := armByName(t, result, "first")
if summary.MedianStepsToFirstViolation != nil {
t.Errorf("median %v, want undefined where fewer than half the runs violate",
*summary.MedianStepsToFirstViolation)
}
if summary.ThirdQuartileSteps != nil {
t.Errorf("third quartile %v, want undefined", *summary.ThirdQuartileSteps)
}
if summary.Usable != 400 {
t.Errorf("%d usable runs, want all 400: a censored run is data", summary.Usable)
}
censoredShare := float64(summary.Censored) / float64(summary.Usable)
if math.Abs(censoredShare-quiet.censoredShare()) > 0.04 {
t.Errorf("censored share %.3f, want the planted %.3f", censoredShare, quiet.censoredShare())
}
expected := populationA12(quiet, loud)
pair := result.Pairwise[0]
if math.Abs(pair.A12-expected) > 0.03 {
t.Errorf("a12 %.4f under heavy censoring, want the planted %.4f", pair.A12, expected)
}
if pair.PValue > 0.01 {
t.Errorf("p-value %v, want the effect to survive the censoring", pair.PValue)
}
if result.LogRank.Observed[0] >= result.LogRank.Expected[0] {
t.Errorf("log-rank observed %v against expected %v for the quiet arm, want fewer than expected",
result.LogRank.Observed[0], result.LogRank.Expected[0])
}
}
// An arm at a real budget is mostly one enormous group of runs censored
// together at it. No exact null distribution covers that, so the conditional
// variance carries the p-value, and it is checked against a permutation p-value
// over the pipeline's own two samples.
func TestPlanted_CensoringAtTheBudgetTracksThePermutationPValue(t *testing.T) {
cases := []struct {
sourceSeed int64
quiet, loud float64
seeds int
}{
{33, 0.0008, 0.0025, 60},
{51, 0.0008, 0.0014, 50},
{52, 0.0006, 0.0012, 50},
{53, 0.0010, 0.0016, 50},
}
discriminating := 0
for _, test := range cases {
quiet := plantedModel{Hazard: test.quiet, Budget: 400}
loud := plantedModel{Hazard: test.loud, Budget: 400}
source := rand.New(rand.NewSource(test.sourceSeed))
quietRuns := quiet.drawCampaign(source, test.seeds)
loudRuns := loud.drawCampaign(source, test.seeds)
root := t.TempDir()
quietDirectory := writePlantedCampaign(t, filepath.Join(root, "quiet"), "quiet", quiet.Budget, quietRuns)
loudDirectory := writePlantedCampaign(t, filepath.Join(root, "loud"), "loud", loud.Budget, loudRuns)
pair := analyseCampaigns(t, loudDirectory, quietDirectory).Pairwise[0]
permuted := permutationGehanTwoSided(plantedObservations(loudRuns), plantedObservations(quietRuns))
if permuted > 0.01 {
discriminating++
}
// The conditional variance and the permutation variance are two
// estimators and not one, so they are near rather than equal. Over these
// four samples the gap is at most 0.011, at a p-value of 0.49 where
// nothing is decided; where a decision is made it is under 0.005. A
// variance wrong by a factor moves the p-value across orders of
// magnitude, which is what this catches.
if math.Abs(pair.PValue-permuted) > 0.015 {
t.Errorf("hazards %v against %v: p-value %.4f, want near the permutation p-value %.4f",
test.quiet, test.loud, pair.PValue, permuted)
}
}
// A permutation p-value already at the floor cannot show a variance moving
// by a fraction, so the check has to include cases that are not overwhelming.
if discriminating < 3 {
t.Fatalf("%d of %d cases carry a p-value large enough to discriminate", discriminating, len(cases))
}
}
func plantedObservations(runs []plantedRun) []observation {
items := make([]observation, 0, len(runs))
for _, run := range runs {
items = append(items, observation{Steps: float64(run.steps), Event: run.violated})
}
return items
}
// permutationGehanTwoSided is the randomization p-value of Gehan's statistic:
// relabel the pooled runs many times, censoring and all, and count how often the
// statistic lands at least as far from zero as the observed one. Both arms here
// censor on the same schedule, which is what makes relabelling them a null.
func permutationGehanTwoSided(first, second []observation) float64 {
pooled := append(append([]observation{}, first...), second...)
observed, _ := gehanReference(first, second)
source := rand.New(rand.NewSource(99))
const shuffles = 20000
extreme := 0
for shuffle := 0; shuffle < shuffles; shuffle++ {
source.Shuffle(len(pooled), func(left, right int) {
pooled[left], pooled[right] = pooled[right], pooled[left]
})
statistic, _ := gehanReference(pooled[:len(first)], pooled[len(first):])
if math.Abs(statistic) >= math.Abs(observed)-1e-9 {
extreme++
}
}
return float64(extreme) / shuffles
}
// Holm has to change an answer somewhere, or its presence in the pipeline is
// decoration. Six arms drawn from hazards close together produce a family where
// uncorrected p-values call several comparisons significant and the correction
// withdraws some of them.
func TestPlanted_HolmWithdrawsAConclusionUncorrectedPValuesReach(t *testing.T) {
budget := 400
hazards := []float64{0.0100, 0.0125, 0.0150, 0.0175, 0.0200, 0.0225}
root := t.TempDir()
source := rand.New(rand.NewSource(4242))
var directories []string
for index, hazard := range hazards {
model := plantedModel{Hazard: hazard, Budget: budget}
name := fmt.Sprintf("arm%d", index)
directories = append(directories, writePlantedCampaign(t,
filepath.Join(root, name), name, budget, model.drawCampaign(source, 40)))
}
result := analyseCampaigns(t, directories...)
if len(result.Pairwise) != 15 {
t.Fatalf("%d comparisons, want 15 over six arms", len(result.Pairwise))
}
if result.HolmFamilySize != 15 {
t.Errorf("holm family size %d, want 15", result.HolmFamilySize)
}
uncorrected, corrected, withdrawn := 0, 0, 0
for _, pair := range result.Pairwise {
if pair.PValue < 0.05 {
uncorrected++
}
if pair.HolmPValue < 0.05 {
corrected++
}
if pair.PValue < 0.05 && pair.HolmPValue >= 0.05 {
withdrawn++
}
}
if withdrawn == 0 {
t.Fatalf("holm withdrew no conclusion: %d of 15 comparisons significant before and after", uncorrected)
}
if corrected >= uncorrected {
t.Errorf("%d comparisons significant uncorrected and %d after holm, want fewer", uncorrected, corrected)
}
}
// Holm is applied within one research question and not across the paper, so the
// same two campaigns analysed on their own must keep their raw p-value while the
// same comparison inside a larger family is corrected against that family.
func TestPlanted_HolmFamilyIsTheInvocationAndNotEveryComparisonEverMade(t *testing.T) {
budget := 400
root := t.TempDir()
source := rand.New(rand.NewSource(777))
var directories []string
for index, hazard := range []float64{0.010, 0.014, 0.018, 0.022} {
model := plantedModel{Hazard: hazard, Budget: budget}
name := fmt.Sprintf("arm%d", index)
directories = append(directories, writePlantedCampaign(t,
filepath.Join(root, name), name, budget, model.drawCampaign(source, 40)))
}
whole := analyseCampaigns(t, append([]string{"--question", "RQ4"}, directories...)...)
alone := analyseCampaigns(t, "--question", "RQ4a", directories[0], directories[1])
if whole.Question != "RQ4" || alone.Question != "RQ4a" {
t.Errorf("questions %q and %q, want them recorded next to the corrected values", whole.Question, alone.Question)
}
if whole.HolmFamilySize != 6 || alone.HolmFamilySize != 1 {
t.Fatalf("family sizes %d and %d, want 6 and 1", whole.HolmFamilySize, alone.HolmFamilySize)
}
if alone.Pairwise[0].HolmPValue != alone.Pairwise[0].PValue {
t.Errorf("holm p %v against raw p %v in a family of one, want them equal",
alone.Pairwise[0].HolmPValue, alone.Pairwise[0].PValue)
}
var inFamily pairwiseResult
for _, pair := range whole.Pairwise {
if pair.First == alone.Pairwise[0].First && pair.Second == alone.Pairwise[0].Second {
inFamily = pair
}
}
if math.Abs(inFamily.PValue-alone.Pairwise[0].PValue) > 1e-12 {
t.Fatalf("the same comparison has raw p %v in one family and %v in the other",
inFamily.PValue, alone.Pairwise[0].PValue)
}
if inFamily.HolmPValue <= alone.Pairwise[0].HolmPValue {
t.Errorf("holm p %v inside a family of six, want it above the %v it carries alone",
inFamily.HolmPValue, alone.Pairwise[0].HolmPValue)
}
}
// The ablation's outcome is a per-seed difference with a sign, so the planted
// shift is applied seed by seed and the pipeline has to return that shift with
// that sign from the campaign directories alone.
func TestPlanted_PairedComparisonRecoversTheShiftAndItsSign(t *testing.T) {
budget := 400
base := plantedModel{Hazard: 0.01, Budget: budget}
const shift = 60
source := rand.New(rand.NewSource(5150))
var post, pre []plantedRun
// Arms are contrasted in the order their labels sort, so the plant is stated
// the same way: post-repair against pre-repair. A seed where the unshifted
// run violated is a pair the shifted run is known to have outlived, whether
// it violated later or ran on clean; a seed where neither violated is a pair
// with no order.
firstSooner, bothViolated, unordered := 0, 0, 0
for seed := int64(1); seed <= 30; seed++ {
fast := base.draw(seed, source)
slow := plantedRun{seed: seed, steps: fast.steps + shift, violated: fast.violated}
if !fast.violated || slow.steps > budget {
slow = plantedRun{seed: seed, steps: budget}
}
post = append(post, fast)
pre = append(pre, slow)
switch {
case !fast.violated:
unordered++
case slow.violated:
bothViolated++
firstSooner++
default:
firstSooner++
}
}
if bothViolated == 0 {
t.Fatal("no pair has two violations, so the planted shift is nowhere the analysis can read it")
}
root := t.TempDir()
preDirectory := writePlantedCampaign(t, filepath.Join(root, "pre"), "pre-repair", budget, pre)
postDirectory := writePlantedCampaign(t, filepath.Join(root, "post"), "post-repair", budget, post)
result := analyseCampaigns(t, "--paired", "--question", "RQ4 ablation", preDirectory, postDirectory)
if result.Paired == nil {
t.Fatal("no paired comparison, want one")
}
if len(result.Pairwise) != 0 {
t.Errorf("%d unpaired comparisons alongside the paired one, want none in a seed-matched design",
len(result.Pairwise))
}
paired := *result.Paired
if paired.First != "post-repair" || paired.Second != "pre-repair" {
t.Fatalf("paired %s minus %s, want post-repair minus pre-repair", paired.First, paired.Second)
}
if paired.Pairs != 30 {
t.Errorf("%d pairs, want 30", paired.Pairs)
}
// Every pair where both runs violated was shifted by exactly the plant, so
// the median over them is the plant itself rather than a mixture of it with
// the step counts censored runs never reached.
if paired.BothViolated != bothViolated {
t.Errorf("%d pair(s) with two violations, want %d", paired.BothViolated, bothViolated)
}
if paired.MedianDifference == nil || *paired.MedianDifference != -shift {
t.Errorf("median difference %v, want the planted %d", paired.MedianDifference, -shift)
}
if paired.Sign != -1 {
t.Errorf("sign %+d, want -1 for the arm that violated sooner", paired.Sign)
}
if paired.FirstSooner != firstSooner || paired.SecondSooner != 0 || paired.Unordered != unordered {
t.Errorf("counts %+v, want %d favouring the shifted arm, none the other way and %d unordered",
paired, firstSooner, unordered)
}
if want := 0.5 * float64(unordered) / 30; paired.A12 != want {
t.Errorf("a12 within pairs %v, want %v where no pair favours the first arm", paired.A12, want)
}
if paired.PValue > 0.001 {
t.Errorf("p-value %v for a shift planted in every pair", paired.PValue)
}
if paired.HolmPValue != paired.PValue {
t.Errorf("holm p %v in a family of one, want the raw %v", paired.HolmPValue, paired.PValue)
}
}
// The paired test is the ablation's decision rule, so a null there has to stay a
// null: two arms drawn from the same model, matched by seed, must not report a
// difference more often than the level allows.
func TestPlanted_PairedNullIsNotCalledSignificantAboveItsLevel(t *testing.T) {
model := plantedModel{Hazard: 0.01, Budget: 400}
const replicates = 200
rejected := 0
for replicate := 0; replicate < replicates; replicate++ {
first, second := plantTwoArms(t, int64(5000+replicate), 30, model, model)
result := analyseCampaigns(t, "--paired", first, second)
if result.Paired.PValue < 0.05 {
rejected++
}
}
if rate := float64(rejected) / replicates; rate > 0.10 {
t.Errorf("the paired test called a true null significant in %.1f%% of %d replicates, want about 5%%",
100*rate, replicates)
}
}