Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(3742): improve user segmentation with BigInt-based random generation #5110

Merged
merged 6 commits into from
Jan 10, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,14 +1,28 @@
import { v4 as uuidV4 } from 'uuid';

import {
generateDeterministicRandomNumber,
isFeatureFlagWithScopeValue,
} from './user-segmentation-utils';

const MOCK_METRICS_IDS = [
'123e4567-e89b-4456-a456-426614174000',
'987fcdeb-51a2-4c4b-9876-543210fedcba',
'a1b2c3d4-e5f6-4890-abcd-ef1234567890',
'f9e8d7c6-b5a4-4210-9876-543210fedcba',
];
const MOCK_METRICS_IDS = {
MOBILE_VALID: '123e4567-e89b-4456-a456-426614174000',
EXTENSION_VALID:
'0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420',
MOBILE_MIN: '00000000-0000-4000-8000-000000000000',
Gudahtt marked this conversation as resolved.
Show resolved Hide resolved
MOBILE_MAX: 'ffffffff-ffff-4fff-bfff-ffffffffffff',
Gudahtt marked this conversation as resolved.
Show resolved Hide resolved
EXTENSION_MIN: `0x${'0'.repeat(64) as string}`,
EXTENSION_MAX: `0x${'f'.repeat(64) as string}`,
UUID_V3: '00000000-0000-3000-8000-000000000000',
INVALID_HEX_NO_PREFIX:
'86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420',
INVALID_HEX_SHORT:
'0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d13642',
INVALID_HEX_LONG:
'0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d1364200',
INVALID_HEX_INVALID_CHARS:
'0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d13642g',
};

const MOCK_FEATURE_FLAGS = {
VALID: {
Expand All @@ -28,26 +42,139 @@ const MOCK_FEATURE_FLAGS = {

describe('user-segmentation-utils', () => {
describe('generateDeterministicRandomNumber', () => {
it('generates consistent numbers for the same input', () => {
const result1 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[0]);
const result2 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[0]);
describe('Mobile client new implementation (uuidv4)', () => {
it('generates consistent results for same uuidv4', () => {
const result1 = generateDeterministicRandomNumber(
MOCK_METRICS_IDS.MOBILE_VALID,
);
const result2 = generateDeterministicRandomNumber(
MOCK_METRICS_IDS.MOBILE_VALID,
);
expect(result1).toBe(result2);
});

expect(result1).toBe(result2);
});
it('handles minimum uuidv4 value', () => {
const result = generateDeterministicRandomNumber(
MOCK_METRICS_IDS.MOBILE_MIN,
);
expect(result).toBe(0);
});

it('handles maximum uuidv4 value', () => {
const result = generateDeterministicRandomNumber(
MOCK_METRICS_IDS.MOBILE_MAX,
);
// For practical purposes, 0.999999 is functionally equivalent to 1 in this context
// the small deviation from exactly 1.0 is a limitation of floating-point arithmetic, not a bug in the logic.
expect(result).toBeCloseTo(1, 5);
});

it('generates numbers between 0 and 1', () => {
MOCK_METRICS_IDS.forEach((id) => {
const result = generateDeterministicRandomNumber(id);
it('results a random number between 0 and 1', () => {
const result = generateDeterministicRandomNumber(
MOCK_METRICS_IDS.MOBILE_VALID,
);
expect(result).toBeGreaterThanOrEqual(0);
expect(result).toBeLessThanOrEqual(1);
});
});

it('generates different numbers for different inputs', () => {
const result1 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[0]);
const result2 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[1]);
describe('Mobile client old implementation and Extension client (hex string)', () => {
it('generates consistent results for same hex', () => {
const result1 = generateDeterministicRandomNumber(
MOCK_METRICS_IDS.EXTENSION_VALID,
);
const result2 = generateDeterministicRandomNumber(
MOCK_METRICS_IDS.EXTENSION_VALID,
);
expect(result1).toBe(result2);
});

it('handles minimum hex value', () => {
const result = generateDeterministicRandomNumber(
MOCK_METRICS_IDS.EXTENSION_MIN,
);
expect(result).toBe(0);
});

it('handles maximum hex value', () => {
const result = generateDeterministicRandomNumber(
MOCK_METRICS_IDS.EXTENSION_MAX,
);
expect(result).toBe(1);
});
});

describe('Distribution validation', () => {
it('produces uniform distribution across 1000 samples', () => {
const samples = 1000;
const buckets = 10;
const tolerance = 0.3;
const distribution = new Array(buckets).fill(0);

// Generate samples using valid UUIDs
Array.from({ length: samples }).forEach(() => {
const uuid = uuidV4();
const value = generateDeterministicRandomNumber(uuid);
const bucketIndex = Math.floor(value * buckets);
// Handle edge case where value === 1
distribution[
bucketIndex === buckets ? buckets - 1 : bucketIndex
] += 1;
});

// Check distribution
const expectedPerBucket = samples / buckets;
const allowedDeviation = expectedPerBucket * tolerance;

distribution.forEach((count) => {
const minExpected = Math.floor(expectedPerBucket - allowedDeviation);
const maxExpected = Math.ceil(expectedPerBucket + allowedDeviation);
expect(count).toBeGreaterThanOrEqual(minExpected);
expect(count).toBeLessThanOrEqual(maxExpected);
});
});
});

describe('MetaMetrics ID validation', () => {
it('throws an error if the MetaMetrics ID is empty', () => {
expect(() => generateDeterministicRandomNumber('')).toThrow(
'MetaMetrics ID cannot be empty',
);
});

it('throws an error if the MetaMetrics ID is not a valid UUIDv4', () => {
expect(() =>
generateDeterministicRandomNumber(MOCK_METRICS_IDS.UUID_V3),
).toThrow('Invalid UUID version. Expected v4, got v3');
});

expect(result1).not.toBe(result2);
it('throws an error if the MetaMetrics ID is not a valid hex string', () => {
expect(() =>
generateDeterministicRandomNumber(
MOCK_METRICS_IDS.INVALID_HEX_NO_PREFIX,
),
).toThrow('Hex ID must start with 0x prefix');
});

it('throws an error if the MetaMetrics ID is a short hex string', () => {
expect(() =>
generateDeterministicRandomNumber(MOCK_METRICS_IDS.INVALID_HEX_SHORT),
).toThrow('Invalid hex ID length. Expected 64 characters, got 63');
});

it('throws an error if the MetaMetrics ID is a long hex string', () => {
expect(() =>
generateDeterministicRandomNumber(MOCK_METRICS_IDS.INVALID_HEX_LONG),
).toThrow('Invalid hex ID length. Expected 64 characters, got 65');
});

it('throws an error if the MetaMetrics ID contains invalid hex characters', () => {
expect(() =>
generateDeterministicRandomNumber(
MOCK_METRICS_IDS.INVALID_HEX_INVALID_CHARS,
),
).toThrow('Hex ID contains invalid characters');
});
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,77 @@
import type { Json } from '@metamask/utils';
import { validate as uuidValidate, version as uuidVersion } from 'uuid';

import type { FeatureFlagScopeValue } from '../remote-feature-flag-controller-types';

/* eslint-disable no-bitwise */
/**
* Converts a UUID string to a BigInt by removing dashes and converting to hexadecimal.
* @param uuid - The UUID string to convert
* @returns The UUID as a BigInt value
*/
function uuidStringToBigInt(uuid: string): bigint {
return BigInt(`0x${uuid.replace(/-/gu, '')}`);
}

const MIN_UUID_V4 = '00000000-0000-4000-8000-000000000000';
const MAX_UUID_V4 = 'ffffffff-ffff-4fff-bfff-ffffffffffff';
const MIN_UUID_V4_BIGINT = uuidStringToBigInt(MIN_UUID_V4);
const MAX_UUID_V4_BIGINT = uuidStringToBigInt(MAX_UUID_V4);
const UUID_V4_VALUE_RANGE_BIGINT = MAX_UUID_V4_BIGINT - MIN_UUID_V4_BIGINT;

/**
* Generates a deterministic random number between 0 and 1 based on a metaMetricsId.
* This is useful for A/B testing and feature flag rollouts where we want
* consistent group assignment for the same user.
*
* @param metaMetricsId - The unique identifier used to generate the deterministic random number
* @returns A number between 0 and 1 that is deterministic for the given metaMetricsId
* @param metaMetricsId - The unique identifier used to generate the deterministic random number. Must be either:
* - A UUIDv4 string (e.g., '123e4567-e89b-12d3-a456-426614174000'
* - A hex string with '0x' prefix (e.g., '0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420')
* @returns A number between 0 and 1, deterministically generated from the input ID.
* The same input will always produce the same output.
*/
export function generateDeterministicRandomNumber(
metaMetricsId: string,
): number {
const hash = [...metaMetricsId].reduce((acc, char) => {
const chr = char.charCodeAt(0);
return ((acc << 5) - acc + chr) | 0;
}, 0);
if (!metaMetricsId) {
throw new Error('MetaMetrics ID cannot be empty');
}

let idValue: bigint;
let maxValue: bigint;

// uuidv4 format
if (uuidValidate(metaMetricsId)) {
if (uuidVersion(metaMetricsId) !== 4) {
throw new Error(
`Invalid UUID version. Expected v4, got v${uuidVersion(metaMetricsId)}`,
);
}
idValue = uuidStringToBigInt(metaMetricsId) - MIN_UUID_V4_BIGINT;
maxValue = UUID_V4_VALUE_RANGE_BIGINT;
} else {
// hex format with 0x prefix
if (!metaMetricsId.startsWith('0x')) {
throw new Error('Hex ID must start with 0x prefix');
}

const cleanId = metaMetricsId.slice(2);
const EXPECTED_HEX_LENGTH = 64; // 32 bytes = 64 hex characters

if (cleanId.length !== EXPECTED_HEX_LENGTH) {
throw new Error(
`Invalid hex ID length. Expected ${EXPECTED_HEX_LENGTH} characters, got ${cleanId.length}`,
);
}

if (!/^[0-9a-f]+$/iu.test(cleanId)) {
throw new Error('Hex ID contains invalid characters');
}

idValue = BigInt(`0x${cleanId}`);
maxValue = BigInt(`0x${'f'.repeat(cleanId.length)}`);
}

return (hash >>> 0) / 0xffffffff;
// Use BigInt division first, then convert to number to maintain precision
Gudahtt marked this conversation as resolved.
Show resolved Hide resolved
return Number((idValue * BigInt(1_000_000)) / maxValue) / 1_000_000;
}

/**
Expand Down
Loading