Skip to content

Commit

Permalink
fix(3742): improve user segmentation with BigInt-based random generation
Browse files Browse the repository at this point in the history
  • Loading branch information
DDDDDanica committed Jan 8, 2025
1 parent 7c38c24 commit c650ada
Show file tree
Hide file tree
Showing 2 changed files with 126 additions and 26 deletions.
Original file line number Diff line number Diff line change
@@ -1,14 +1,19 @@
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',
MOBILE_MAX: 'ffffffff-ffff-4fff-bfff-ffffffffffff',
EXTENSION_MIN: `0x${'0'.repeat(64) as string}`,
EXTENSION_MAX: `0x${'f'.repeat(64) as string}`,
};

const MOCK_FEATURE_FLAGS = {
VALID: {
Expand All @@ -28,26 +33,97 @@ 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('generates numbers between 0 and 1', () => {
MOCK_METRICS_IDS.forEach((id) => {
const result = generateDeterministicRandomNumber(id);
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('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);
});

expect(result1).not.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);
});
});
});
});

Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,49 @@
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 */
/**
* 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
* Supports two metaMetricsId formats:
* - UUIDv4 format (new mobile implementation)
* - Hex format with 0x prefix (extension or old mobile implementation)
*
* For UUIDv4 format, the following normalizations are applied:
* - Replaces version (4) bits with 'f' to normalize range
* - Replaces variant bits (8-b) with 'f' to normalize range
* - Removes all dashes from the UUID
*
* For hex format:
* - Expects a hex string with '0x' prefix (e.g., '0x1234abcd')
* - Removes the '0x' prefix before conversion
* - Converts the remaining hex string to a BigInt for calculation
*
* @param metaMetricsId - The unique identifier used to generate the deterministic random number, can be a UUIDv4 or hex string
* @returns A number between 0 and 1 that is deterministic for the given metaMetricsId
*/
export function generateDeterministicRandomNumber(
metaMetricsId: string,
): number {
const hash = [...metaMetricsId].reduce((acc, char) => {
const chr = char.charCodeAt(0);
return ((acc << 5) - acc + chr) | 0;
}, 0);

return (hash >>> 0) / 0xffffffff;
let cleanId: string, value: bigint;
// uuidv4 format
if (uuidValidate(metaMetricsId) && uuidVersion(metaMetricsId) === 4) {
cleanId = metaMetricsId
.replace(/^(.{12})4/u, '$1f')
.replace(/(.{16})[89ab]/u, '$1f')
.replace(/-/gu, '');
value = BigInt(`0x${cleanId}`);
} else {
// hex format with 0x prefix
cleanId = metaMetricsId.slice(2);
value = BigInt(`0x${cleanId}`);
}
const maxValue = BigInt(`0x${'f'.repeat(cleanId.length)}`);
// Use BigInt division first, then convert to number to maintain precision
return Number((value * BigInt(1000000)) / maxValue) / 1000000;
}

/**
Expand Down

0 comments on commit c650ada

Please sign in to comment.