Last active
August 22, 2025 13:35
-
-
Save CezaryDanielNowak/1e0003819a3b954fdf8d99d8e3b4da59 to your computer and use it in GitHub Desktop.
100000 loops. Every loop creates 1000 sessions. Max deviation was 7.5%. For 3000 sessions: goes down to 4.1%
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
const secureRand = (() => { | |
const crypto = globalThis.crypto || require('crypto').webcrypto; | |
const maxUint32 = 0xFFFFFFFF; | |
const buffer = new Uint32Array(1); | |
return () => { | |
// Fill the array with random values | |
crypto.getRandomValues(buffer); | |
// Convert the random value into a float between 0 and 1 | |
return buffer[0] / maxUint32; | |
}; | |
})(); | |
const simpleRand = () => Math.random(); | |
const mockedRand = (() => { | |
let seed = 1; | |
const m = 2 ** 31 - 1; | |
const a = 48271; | |
const c = 0; | |
const func = () => { | |
seed = (a * seed + c) % m; | |
return seed / m; | |
}; | |
func.reset = () => { seed = 1; }; | |
return func; | |
})(); | |
const mockedRand2 = (() => { | |
let seed; | |
const func = () => { | |
seed = (1664525 * seed + 1013904223) % 4294967296; | |
return seed / 4294967296; | |
}; | |
func.reset = () => { | |
seed = 12345; | |
}; | |
func.reset(); | |
return func; | |
})(); | |
const cases = [ | |
{ name: 'case A', frequency: 1/3 }, | |
{ name: 'case B', frequency: 1/5 }, | |
{ name: 'case C', frequency: 1/30 }, | |
{ name: 'case D', frequency: 1/30 }, | |
]; | |
const DEFAULT_CASE = { | |
name: 'default', | |
frequency: 1 - cases.reduce((acc, { frequency }) => { return frequency + acc }, 0), | |
}; | |
let maxDeviation = 0; | |
for (let _ = 0; _ < 100000; _++) { | |
/** | |
* Possible case selectors: selectABTestCase1, selectABTestCase2, selectABTestCase3 | |
* Possible randomGenerators: secureRand, simpleRand, mockedRand, mockedRand2 | |
*/ | |
maxDeviation = Math.max( | |
testCase(1000, selectABTestCase1, mockedRand2), | |
maxDeviation | |
); | |
} | |
console.log('maxDeviation', maxDeviation); | |
/* HELPERS */ | |
// Performs a cumulative sum and compares with a random value. | |
// Returns maxDeviation | |
function testCase (SESSIONS_SAMPLE_NUMBER, caseSelector, randomGenerator) { | |
if (randomGenerator.reset) { | |
randomGenerator.reset(); | |
} | |
const results = {}; | |
// selecting case and assigning number of sesions in results object. | |
// Example {"case A": 333, "case B": 200} | |
for (let _ = 0; _ < SESSIONS_SAMPLE_NUMBER; _++) { | |
const selectedCase = caseSelector(cases, randomGenerator); | |
results[selectedCase.name] = (results[selectedCase.name] || 0) + 1; | |
} | |
// calculating how much deviation we get after 1000 sessions from the intended frequency. | |
// for frequency 0.2 we expect 200 sessions. 204 sessions means 2% deviation | |
let maxDeviation = 0; | |
Object.entries(results).forEach(([ caseName, timesTriggered ]) => { | |
let caseFrequency = cases.find(({ name }) => name === caseName); | |
caseFrequency = caseFrequency | |
? caseFrequency.frequency | |
: DEFAULT_CASE.frequency; | |
// console.log(caseName, timesTriggered / SESSIONS_SAMPLE_NUMBER, 'of', caseFrequency); | |
maxDeviation = Math.max( | |
Math.abs((timesTriggered / SESSIONS_SAMPLE_NUMBER) - caseFrequency), | |
maxDeviation, | |
); | |
}); | |
return maxDeviation; | |
} | |
/** | |
* Selects an A/B testing case based on independent frequencies (probabilities). | |
* Frequencies are between 0 and 1. | |
* @param {Object} cases - Object where keys are case names and values are probabilities. | |
* @returns {Object} - Selected case name or default case if none triggered. | |
* | |
* NOTE: this selection methods seems to have the least deviation score | |
*/ | |
function selectABTestCase1(cases, randomGenerator) { | |
const random = randomGenerator(); | |
let cumulative = 0; | |
for (const currCase of cases) { | |
if (currCase.frequency < 0 || currCase.frequency > 1) { | |
throw new Error(`Invalid frequency for ${currCase.name}: must be between 0 and 1`); | |
} | |
const start = cumulative; | |
const end = cumulative + currCase.frequency; | |
if (random >= start && random < end) { | |
return currCase; | |
} | |
cumulative = end; | |
} | |
return DEFAULT_CASE; | |
} | |
// Duplicates cases according to their frequency, then picks a random index (memory hungry). | |
function selectABTestCase2(cases, randomGenerator) { | |
const random = randomGenerator(); | |
const weightedCases = cases.flatMap(testCase => Array(Math.floor(1 / testCase.frequency)).fill(testCase)); | |
const randomIndex = Math.floor(random * weightedCases.length); | |
return weightedCases[randomIndex] || DEFAULT_CASE; | |
} | |
// Uses cumulative frequencies and performs a binary search. | |
function selectABTestCase3(cases, randomGenerator) { | |
const cumulativeFrequencies = []; | |
let total = 0; | |
// Calculate cumulative frequencies | |
cases.forEach(testCase => { | |
total += testCase.frequency; | |
cumulativeFrequencies.push(total); | |
}); | |
const random = randomGenerator() * total; | |
// Binary search for the test case | |
for (let i = 0; i < cumulativeFrequencies.length; i++) { | |
if (random <= cumulativeFrequencies[i]) { | |
return cases[i]; | |
} | |
} | |
return DEFAULT_CASE; // Fallback case | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment