1const assertFiniteNumbers = (values, label) => {
2 if (!Array.isArray(values) || values.length === 0) {
3 throw new Error(`${label} must be a non-empty array`)
4 }
5 for (const value of values) {
6 if (!Number.isFinite(value) || value <= 0) {
7 throw new Error(`${label} must contain finite positive numbers`)
8 }
9 }
10}
11
12export const median = (values) => {
13 if (!Array.isArray(values) || values.length === 0) {
14 throw new Error("values must be a non-empty array")
15 }
16 const sorted = values.slice().sort((a, b) => a - b)
17 const middle = Math.floor(sorted.length / 2)
18 return sorted.length % 2 === 0
19 ? (sorted[middle - 1] + sorted[middle]) / 2
20 : sorted[middle]
21}
22
23export const percentile = (values, probability) => {
24 if (!Array.isArray(values) || values.length === 0) {
25 throw new Error("values must be a non-empty array")
26 }
27 if (!Number.isFinite(probability) || probability < 0 || probability > 1) {
28 throw new Error("probability must be between 0 and 1")
29 }
30 const sorted = values.slice().sort((a, b) => a - b)
31 const index = (sorted.length - 1) * probability
32 const lower = Math.floor(index)
33 const upper = Math.ceil(index)
34 if (lower === upper) return sorted[lower]
35 const weight = index - lower
36 return sorted[lower] * (1 - weight) + sorted[upper] * weight
37}
38
39const makeRandom = (seed) => {
40 let state = seed >>> 0
41 return () => {
42 state += 0x6d2b79f5
43 let value = state
44 value = Math.imul(value ^ value >>> 15, value | 1)
45 value ^= value + Math.imul(value ^ value >>> 7, value | 61)
46 return ((value ^ value >>> 14) >>> 0) / 4294967296
47 }
48}
49
50export const aggregate = (values) => {
51 assertFiniteNumbers(values, "values")
52 const center = median(values)
53 return {
54 median: center,
55 min: Math.min(...values),
56 max: Math.max(...values),
57 mad: median(values.map((value) => Math.abs(value - center)))
58 }
59}
60
61export const bootstrapMedianLogRatio = (
62 ratios,
63 { confidence = 0.95, iterations = 10_000, seed = 0x5eed1234 } = {}
64) => {
65 assertFiniteNumbers(ratios, "ratios")
66 if (!Number.isInteger(iterations) || iterations <= 0) {
67 throw new Error("iterations must be a positive integer")
68 }
69 if (!Number.isFinite(confidence) || confidence <= 0 || confidence >= 1) {
70 throw new Error("confidence must be between 0 and 1")
71 }
72 if (!Number.isInteger(seed)) {
73 throw new Error("seed must be an integer")
74 }
75
76 const logRatios = ratios.map(Math.log)
77 const random = makeRandom(seed)
78 const samples = new Array(iterations)
79 const resample = new Array(logRatios.length)
80 for (let iteration = 0; iteration < iterations; iteration++) {
81 for (let index = 0; index < logRatios.length; index++) {
82 resample[index] = logRatios[Math.floor(random() * logRatios.length)]
83 }
84 samples[iteration] = median(resample)
85 }
86 const tail = (1 - confidence) / 2
87 return {
88 ratio: Math.exp(median(logRatios)),
89 lowRatio: Math.exp(percentile(samples, tail)),
90 highRatio: Math.exp(percentile(samples, 1 - tail)),
91 confidence,
92 iterations,
93 seed
94 }
95}
96
97export const analyzePairs = (
98 base,
99 head,
100 {
101 confidence = 0.95,
102 iterations = 10_000,
103 seed = 0x5eed1234,
104 minImprovementPercent = 2,
105 maxRegressionPercent = 5
106 } = {}
107) => {
108 assertFiniteNumbers(base, "base")
109 assertFiniteNumbers(head, "head")
110 if (base.length !== head.length) {
111 throw new Error("base and head must contain the same number of observations")
112 }
113 const ratios = base.map((value, index) => head[index] / value)
114 const interval = bootstrapMedianLogRatio(ratios, { confidence, iterations, seed })
115 const deltaPercent = (interval.ratio - 1) * 100
116 const lowPercent = (interval.lowRatio - 1) * 100
117 const highPercent = (interval.highRatio - 1) * 100
118 const status = interval.highRatio < 1 - minImprovementPercent / 100
119 ? "improvement"
120 : interval.lowRatio > 1 + maxRegressionPercent / 100
121 ? "regression"
122 : "inconclusive"
123 return {
124 ratios,
125 deltaPercent,
126 lowPercent,
127 highPercent,
128 status,
129 ...interval
130 }
131}