Skip to content

Commit

Permalink
fix(3742): improve user segmentation with BigInt-based random generat…
Browse files Browse the repository at this point in the history
…ion (#5110)

## Explanation

Replace hash-based random number generation with BigInt-based
implementation for better distribution and format support. The new
implementation properly handles both UUIDv4 and hex-format
metaMetricsIds, providing more consistent
and reliable user segmentation.
- Add support for UUIDv4 format with proper bit normalization
- Improve hex format handling using BigInt for precise calculations
- Remove char-by-char hashing algorithm to prevent potential collisions

<!--
Thanks for your contribution! Take a moment to answer these questions so
that reviewers have the information they need to properly understand
your changes:

* What is the current state of things and why does it need to change?
* What is the solution your changes offer and how does it work?
* Are there any changes whose purpose might not obvious to those
unfamiliar with the domain?
* If your primary goal was to update one package but you found you had
to update another one along the way, why did you do so?
* If you had to upgrade a dependency, why did you do so?
-->

## References
Addresses:
#5051 (comment)
<!--
Are there any issues that this pull request is tied to?
Are there other links that reviewers should consult to understand these
changes better?
Are there client or consumer pull requests to adopt any breaking
changes?

For example:

* Fixes #12345
* Related to #67890
-->

## Changelog

<!--
If you're making any consumer-facing changes, list those changes here as
if you were updating a changelog, using the template below as a guide.

(CATEGORY is one of BREAKING, ADDED, CHANGED, DEPRECATED, REMOVED, or
FIXED. For security-related issues, follow the Security Advisory
process.)

Please take care to name the exact pieces of the API you've added or
changed (e.g. types, interfaces, functions, or methods).

If there are any breaking changes, make sure to offer a solution for
consumers to follow once they upgrade to the changes.

Finally, if you're only making changes to development scripts or tests,
you may replace the template below with "None".
-->

### `@metamask/remote-feature-flag-controller`
- **CHANGED**: Modify `generateDeterministicRandomNumber` to handle both
uuidv4(mobile new) and hex(mobile old and extension) side

## Checklist

- [x] I've updated the test suite for new or updated code as appropriate
- [x] I've updated documentation (JSDoc, Markdown, etc.) for new or
updated code as appropriate
- [x] I've highlighted breaking changes using the "BREAKING" category
above as appropriate
- [x] I've prepared draft pull requests for clients and consumer
packages to resolve any breaking changes
  • Loading branch information
DDDDDanica authored Jan 10, 2025
1 parent 3b94a7c commit 0875256
Show file tree
Hide file tree
Showing 2 changed files with 206 additions and 27 deletions.
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',
MOBILE_MAX: 'ffffffff-ffff-4fff-bfff-ffffffffffff',
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
return Number((idValue * BigInt(1_000_000)) / maxValue) / 1_000_000;
}

/**
Expand Down

0 comments on commit 0875256

Please sign in to comment.