Skip to content

Commit c650ada

Browse files
committed
fix(3742): improve user segmentation with BigInt-based random generation
1 parent 7c38c24 commit c650ada

File tree

2 files changed

+126
-26
lines changed

2 files changed

+126
-26
lines changed

packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.test.ts

Lines changed: 94 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,19 @@
1+
import { v4 as uuidV4 } from 'uuid';
2+
13
import {
24
generateDeterministicRandomNumber,
35
isFeatureFlagWithScopeValue,
46
} from './user-segmentation-utils';
57

6-
const MOCK_METRICS_IDS = [
7-
'123e4567-e89b-4456-a456-426614174000',
8-
'987fcdeb-51a2-4c4b-9876-543210fedcba',
9-
'a1b2c3d4-e5f6-4890-abcd-ef1234567890',
10-
'f9e8d7c6-b5a4-4210-9876-543210fedcba',
11-
];
8+
const MOCK_METRICS_IDS = {
9+
MOBILE_VALID: '123e4567-e89b-4456-a456-426614174000',
10+
EXTENSION_VALID:
11+
'0x86bacb9b2bf9a7e8d2b147eadb95ac9aaa26842327cd24afc8bd4b3c1d136420',
12+
MOBILE_MIN: '00000000-0000-4000-8000-000000000000',
13+
MOBILE_MAX: 'ffffffff-ffff-4fff-bfff-ffffffffffff',
14+
EXTENSION_MIN: `0x${'0'.repeat(64) as string}`,
15+
EXTENSION_MAX: `0x${'f'.repeat(64) as string}`,
16+
};
1217

1318
const MOCK_FEATURE_FLAGS = {
1419
VALID: {
@@ -28,26 +33,97 @@ const MOCK_FEATURE_FLAGS = {
2833

2934
describe('user-segmentation-utils', () => {
3035
describe('generateDeterministicRandomNumber', () => {
31-
it('generates consistent numbers for the same input', () => {
32-
const result1 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[0]);
33-
const result2 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[0]);
36+
describe('Mobile client new implementation (uuidv4)', () => {
37+
it('generates consistent results for same uuidv4', () => {
38+
const result1 = generateDeterministicRandomNumber(
39+
MOCK_METRICS_IDS.MOBILE_VALID,
40+
);
41+
const result2 = generateDeterministicRandomNumber(
42+
MOCK_METRICS_IDS.MOBILE_VALID,
43+
);
44+
expect(result1).toBe(result2);
45+
});
3446

35-
expect(result1).toBe(result2);
36-
});
47+
it('handles minimum uuidv4 value', () => {
48+
const result = generateDeterministicRandomNumber(
49+
MOCK_METRICS_IDS.MOBILE_MIN,
50+
);
51+
expect(result).toBe(0);
52+
});
3753

38-
it('generates numbers between 0 and 1', () => {
39-
MOCK_METRICS_IDS.forEach((id) => {
40-
const result = generateDeterministicRandomNumber(id);
54+
it('handles maximum uuidv4 value', () => {
55+
const result = generateDeterministicRandomNumber(
56+
MOCK_METRICS_IDS.MOBILE_MAX,
57+
);
58+
// For practical purposes, 0.999999 is functionally equivalent to 1 in this context
59+
// the small deviation from exactly 1.0 is a limitation of floating-point arithmetic, not a bug in the logic.
60+
expect(result).toBeCloseTo(1, 5);
61+
});
62+
63+
it('results a random number between 0 and 1', () => {
64+
const result = generateDeterministicRandomNumber(
65+
MOCK_METRICS_IDS.MOBILE_VALID,
66+
);
4167
expect(result).toBeGreaterThanOrEqual(0);
4268
expect(result).toBeLessThanOrEqual(1);
4369
});
4470
});
4571

46-
it('generates different numbers for different inputs', () => {
47-
const result1 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[0]);
48-
const result2 = generateDeterministicRandomNumber(MOCK_METRICS_IDS[1]);
72+
describe('Mobile client old implementation and Extension client (hex string)', () => {
73+
it('generates consistent results for same hex', () => {
74+
const result1 = generateDeterministicRandomNumber(
75+
MOCK_METRICS_IDS.EXTENSION_VALID,
76+
);
77+
const result2 = generateDeterministicRandomNumber(
78+
MOCK_METRICS_IDS.EXTENSION_VALID,
79+
);
80+
expect(result1).toBe(result2);
81+
});
4982

50-
expect(result1).not.toBe(result2);
83+
it('handles minimum hex value', () => {
84+
const result = generateDeterministicRandomNumber(
85+
MOCK_METRICS_IDS.EXTENSION_MIN,
86+
);
87+
expect(result).toBe(0);
88+
});
89+
90+
it('handles maximum hex value', () => {
91+
const result = generateDeterministicRandomNumber(
92+
MOCK_METRICS_IDS.EXTENSION_MAX,
93+
);
94+
expect(result).toBe(1);
95+
});
96+
});
97+
98+
describe('Distribution validation', () => {
99+
it('produces uniform distribution across 1000 samples', () => {
100+
const samples = 1000;
101+
const buckets = 10;
102+
const tolerance = 0.3;
103+
const distribution = new Array(buckets).fill(0);
104+
105+
// Generate samples using valid UUIDs
106+
Array.from({ length: samples }).forEach(() => {
107+
const uuid = uuidV4();
108+
const value = generateDeterministicRandomNumber(uuid);
109+
const bucketIndex = Math.floor(value * buckets);
110+
// Handle edge case where value === 1
111+
distribution[
112+
bucketIndex === buckets ? buckets - 1 : bucketIndex
113+
] += 1;
114+
});
115+
116+
// Check distribution
117+
const expectedPerBucket = samples / buckets;
118+
const allowedDeviation = expectedPerBucket * tolerance;
119+
120+
distribution.forEach((count) => {
121+
const minExpected = Math.floor(expectedPerBucket - allowedDeviation);
122+
const maxExpected = Math.ceil(expectedPerBucket + allowedDeviation);
123+
expect(count).toBeGreaterThanOrEqual(minExpected);
124+
expect(count).toBeLessThanOrEqual(maxExpected);
125+
});
126+
});
51127
});
52128
});
53129

packages/remote-feature-flag-controller/src/utils/user-segmentation-utils.ts

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,49 @@
11
import type { Json } from '@metamask/utils';
2+
import { validate as uuidValidate, version as uuidVersion } from 'uuid';
23

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

5-
/* eslint-disable no-bitwise */
66
/**
77
* Generates a deterministic random number between 0 and 1 based on a metaMetricsId.
88
* This is useful for A/B testing and feature flag rollouts where we want
99
* consistent group assignment for the same user.
1010
*
11-
* @param metaMetricsId - The unique identifier used to generate the deterministic random number
11+
* Supports two metaMetricsId formats:
12+
* - UUIDv4 format (new mobile implementation)
13+
* - Hex format with 0x prefix (extension or old mobile implementation)
14+
*
15+
* For UUIDv4 format, the following normalizations are applied:
16+
* - Replaces version (4) bits with 'f' to normalize range
17+
* - Replaces variant bits (8-b) with 'f' to normalize range
18+
* - Removes all dashes from the UUID
19+
*
20+
* For hex format:
21+
* - Expects a hex string with '0x' prefix (e.g., '0x1234abcd')
22+
* - Removes the '0x' prefix before conversion
23+
* - Converts the remaining hex string to a BigInt for calculation
24+
*
25+
* @param metaMetricsId - The unique identifier used to generate the deterministic random number, can be a UUIDv4 or hex string
1226
* @returns A number between 0 and 1 that is deterministic for the given metaMetricsId
1327
*/
1428
export function generateDeterministicRandomNumber(
1529
metaMetricsId: string,
1630
): number {
17-
const hash = [...metaMetricsId].reduce((acc, char) => {
18-
const chr = char.charCodeAt(0);
19-
return ((acc << 5) - acc + chr) | 0;
20-
}, 0);
21-
22-
return (hash >>> 0) / 0xffffffff;
31+
let cleanId: string, value: bigint;
32+
// uuidv4 format
33+
if (uuidValidate(metaMetricsId) && uuidVersion(metaMetricsId) === 4) {
34+
cleanId = metaMetricsId
35+
.replace(/^(.{12})4/u, '$1f')
36+
.replace(/(.{16})[89ab]/u, '$1f')
37+
.replace(/-/gu, '');
38+
value = BigInt(`0x${cleanId}`);
39+
} else {
40+
// hex format with 0x prefix
41+
cleanId = metaMetricsId.slice(2);
42+
value = BigInt(`0x${cleanId}`);
43+
}
44+
const maxValue = BigInt(`0x${'f'.repeat(cleanId.length)}`);
45+
// Use BigInt division first, then convert to number to maintain precision
46+
return Number((value * BigInt(1000000)) / maxValue) / 1000000;
2347
}
2448

2549
/**

0 commit comments

Comments
 (0)