feat(experiments): target rollouts by guild and premium status (#3012)

This commit is contained in:
Hampus
2026-09-28 14:34:19 +02:00
committed by GitHub
parent 564c5ae164
commit eaee820216
22 changed files with 803 additions and 65 deletions
@@ -8,9 +8,11 @@ import {
DEFAULT_ALTCHA_CAPTCHA_CONFIG,
resolveAltchaCaptchaAssignment,
} from '@fluxer/schema/src/domains/admin/AltchaCaptchaSchemas';
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {type ExperimentTargeting, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {describe, expect, test} from 'vitest';
const NO_TARGETING: ExperimentTargeting = {memberGuildIds: new Set(), premium: false};
const TARGETED_USER_ID = '1000000000000000001';
function createConfig(overrides: Partial<AltchaCaptchaConfig> = {}): AltchaCaptchaConfig {
@@ -30,6 +32,8 @@ describe('altcha captcha configuration', () => {
rollout_salt: 'altcha-captcha-v1',
included_user_ids: [],
excluded_user_ids: [],
included_guild_ids: [],
include_premium_users: false,
anonymous_enabled: false,
cost: 5000,
max_counter: 10000,
@@ -48,7 +52,7 @@ describe('altcha captcha configuration', () => {
describe('resolveAltchaCaptchaAssignment', () => {
test('serves nobody while disabled, even included users', () => {
const config = createConfig({rollout_basis_points: 10000, included_user_ids: [TARGETED_USER_ID]});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
test('applies exclusions before inclusions', () => {
@@ -57,18 +61,18 @@ describe('resolveAltchaCaptchaAssignment', () => {
included_user_ids: [TARGETED_USER_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
test('serves included users at zero rollout', () => {
const config = createConfig({enabled: true, included_user_ids: [TARGETED_USER_ID]});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID)).toEqual({enabled: true});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: true});
});
test('buckets the rollout by salt and user id', () => {
const config = createConfig({enabled: true, rollout_basis_points: 2500});
for (const userId of syntheticUserIds(200)) {
expect(resolveAltchaCaptchaAssignment(config, userId).enabled).toBe(
expect(resolveAltchaCaptchaAssignment(config, userId, NO_TARGETING).enabled).toBe(
experimentBucket(userId, config.rollout_salt) < 2500,
);
}
@@ -77,13 +81,65 @@ describe('resolveAltchaCaptchaAssignment', () => {
describe('altchaCaptchaAppliesTo', () => {
test('serves anonymous requests only when anonymous_enabled is set', () => {
expect(altchaCaptchaAppliesTo(createConfig({enabled: true}), null)).toBe(false);
expect(altchaCaptchaAppliesTo(createConfig({enabled: true, anonymous_enabled: true}), null)).toBe(true);
expect(altchaCaptchaAppliesTo(createConfig({anonymous_enabled: true}), null)).toBe(false);
expect(altchaCaptchaAppliesTo(createConfig({enabled: true}), null, NO_TARGETING)).toBe(false);
expect(altchaCaptchaAppliesTo(createConfig({enabled: true, anonymous_enabled: true}), null, NO_TARGETING)).toBe(
true,
);
expect(altchaCaptchaAppliesTo(createConfig({anonymous_enabled: true}), null, NO_TARGETING)).toBe(false);
});
test('keeps signed-in users on their own bucket regardless of the anonymous switch', () => {
const config = createConfig({enabled: true, anonymous_enabled: true, excluded_user_ids: [TARGETED_USER_ID]});
expect(altchaCaptchaAppliesTo(config, TARGETED_USER_ID)).toBe(false);
expect(altchaCaptchaAppliesTo(config, TARGETED_USER_ID, NO_TARGETING)).toBe(false);
});
});
describe('resolveAltchaCaptchaAssignment guild targeting', () => {
const INCLUDED_GUILD_ID = '3000000000000000001';
const MEMBER_GUILDS: ExperimentTargeting = {
memberGuildIds: new Set(['3000000000000000009', INCLUDED_GUILD_ID]),
premium: false,
};
test('serves members of an included guild at zero rollout', () => {
const config = createConfig({enabled: true, included_guild_ids: [INCLUDED_GUILD_ID]});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: true});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
test('keeps user exclusions ahead of guild membership', () => {
const config = createConfig({
enabled: true,
included_guild_ids: [INCLUDED_GUILD_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
});
test('serves no guild members while disabled', () => {
const config = createConfig({included_guild_ids: [INCLUDED_GUILD_ID]});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
});
});
describe('resolveAltchaCaptchaAssignment premium targeting', () => {
const PREMIUM: ExperimentTargeting = {memberGuildIds: new Set(), premium: true};
test('serves premium users only when the switch is on', () => {
const on = createConfig({enabled: true, include_premium_users: true});
expect(resolveAltchaCaptchaAssignment(on, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: true});
expect(resolveAltchaCaptchaAssignment(on, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
expect(resolveAltchaCaptchaAssignment(createConfig({enabled: true}), TARGETED_USER_ID, PREMIUM)).toEqual({
enabled: false,
});
});
test('keeps user exclusions ahead of the premium switch', () => {
const config = createConfig({
enabled: true,
include_premium_users: true,
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveAltchaCaptchaAssignment(config, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: false});
});
});
@@ -1,6 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {
EXPERIMENT_BUCKET_RESOLUTION,
type ExperimentTargeting,
experimentAudienceIncludes,
experimentBucket,
} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {z} from 'zod';
const ALTCHA_CAPTCHA_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
@@ -23,6 +28,8 @@ const altchaCaptchaConfigFields = {
rollout_basis_points: z.number().int().min(0).max(ALTCHA_CAPTCHA_ROLLOUT_BASIS_POINTS_MAX),
rollout_salt: z.string().trim().min(1).max(64).regex(ALTCHA_CAPTCHA_SALT_PATTERN),
included_user_ids: AltchaCaptchaTargetedUserIdsSchema,
included_guild_ids: AltchaCaptchaTargetedUserIdsSchema,
include_premium_users: z.boolean(),
excluded_user_ids: AltchaCaptchaTargetedUserIdsSchema,
anonymous_enabled: z.boolean(),
cost: z.number().int().min(ALTCHA_CAPTCHA_MIN_COST).max(ALTCHA_CAPTCHA_MAX_COST),
@@ -35,6 +42,8 @@ export const AltchaCaptchaConfigSchema = z.object({
rollout_basis_points: altchaCaptchaConfigFields.rollout_basis_points.default(0),
rollout_salt: altchaCaptchaConfigFields.rollout_salt.default(DEFAULT_ALTCHA_CAPTCHA_SALT),
included_user_ids: altchaCaptchaConfigFields.included_user_ids.default([]),
included_guild_ids: altchaCaptchaConfigFields.included_guild_ids.default([]),
include_premium_users: altchaCaptchaConfigFields.include_premium_users.default(false),
excluded_user_ids: altchaCaptchaConfigFields.excluded_user_ids.default([]),
anonymous_enabled: altchaCaptchaConfigFields.anonymous_enabled.default(false),
cost: altchaCaptchaConfigFields.cost.default(5000),
@@ -69,14 +78,20 @@ export const INERT_ALTCHA_CAPTCHA_ASSIGNMENT: AltchaCaptchaAssignmentResponse =
export function resolveAltchaCaptchaAssignment(
config: AltchaCaptchaConfig,
userId: string,
targeting: ExperimentTargeting,
): AltchaCaptchaAssignmentResponse {
if (!config.enabled) return {...INERT_ALTCHA_CAPTCHA_ASSIGNMENT};
if (config.excluded_user_ids.includes(userId)) return {...INERT_ALTCHA_CAPTCHA_ASSIGNMENT};
if (config.included_user_ids.includes(userId)) return {enabled: true};
if (experimentAudienceIncludes(config, targeting)) return {enabled: true};
return {enabled: experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points};
}
export function altchaCaptchaAppliesTo(config: AltchaCaptchaConfig, userId: string | null): boolean {
export function altchaCaptchaAppliesTo(
config: AltchaCaptchaConfig,
userId: string | null,
targeting: ExperimentTargeting,
): boolean {
if (userId === null) return config.enabled && config.anonymous_enabled;
return resolveAltchaCaptchaAssignment(config, userId).enabled;
return resolveAltchaCaptchaAssignment(config, userId, targeting).enabled;
}
@@ -9,9 +9,11 @@ import {
resolveDomainMigrationAssignment,
toDomainMigrationDiscovery,
} from '@fluxer/schema/src/domains/admin/DomainMigrationSchemas';
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {type ExperimentTargeting, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {describe, expect, test} from 'vitest';
const NO_TARGETING: ExperimentTargeting = {memberGuildIds: new Set(), premium: false};
const TARGETED_USER_ID = '1000000000000000001';
const OTHER_USER_ID = '1000000000000000002';
@@ -35,7 +37,7 @@ function syntheticUserIds(count: number): Array<string> {
function targetedUserIds(config: DomainMigrationConfig, userIds: ReadonlyArray<string>): Set<string> {
const targeted = new Set<string>();
for (const userId of userIds) {
if (resolveDomainMigrationAssignment(config, userId).enabled) {
if (resolveDomainMigrationAssignment(config, userId, NO_TARGETING).enabled) {
targeted.add(userId);
}
}
@@ -110,17 +112,19 @@ describe('resolveDomainMigrationAssignment', () => {
rollout_basis_points: 10000,
included_user_ids: [TARGETED_USER_ID],
});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID)).toEqual(INERT_DOMAIN_MIGRATION_ASSIGNMENT);
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual(
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
);
});
test('returns the inert assignment for the default config', () => {
expect(resolveDomainMigrationAssignment(createConfig(), TARGETED_USER_ID)).toEqual(
expect(resolveDomainMigrationAssignment(createConfig(), TARGETED_USER_ID, NO_TARGETING)).toEqual(
INERT_DOMAIN_MIGRATION_ASSIGNMENT,
);
});
test('never hands back the shared inert object', () => {
const assignment = resolveDomainMigrationAssignment(createConfig(), TARGETED_USER_ID);
const assignment = resolveDomainMigrationAssignment(createConfig(), TARGETED_USER_ID, NO_TARGETING);
expect(assignment).not.toBe(INERT_DOMAIN_MIGRATION_ASSIGNMENT);
});
@@ -130,7 +134,7 @@ describe('resolveDomainMigrationAssignment', () => {
included_user_ids: [TARGETED_USER_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID).enabled).toBe(false);
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, NO_TARGETING).enabled).toBe(false);
});
test('denylist beats the bucket', () => {
@@ -139,8 +143,8 @@ describe('resolveDomainMigrationAssignment', () => {
rollout_basis_points: 10000,
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID).enabled).toBe(false);
expect(resolveDomainMigrationAssignment(config, OTHER_USER_ID).enabled).toBe(true);
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, NO_TARGETING).enabled).toBe(false);
expect(resolveDomainMigrationAssignment(config, OTHER_USER_ID, NO_TARGETING).enabled).toBe(true);
});
test('allowlist beats the bucket', () => {
@@ -149,8 +153,8 @@ describe('resolveDomainMigrationAssignment', () => {
rollout_basis_points: 0,
included_user_ids: [TARGETED_USER_ID],
});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID).enabled).toBe(true);
expect(resolveDomainMigrationAssignment(config, OTHER_USER_ID).enabled).toBe(false);
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, NO_TARGETING).enabled).toBe(true);
expect(resolveDomainMigrationAssignment(config, OTHER_USER_ID, NO_TARGETING).enabled).toBe(false);
});
test.each([
@@ -159,7 +163,7 @@ describe('resolveDomainMigrationAssignment', () => {
])('a rollout of $basisPoints basis points targets $enabled', ({basisPoints, enabled}) => {
const config = createConfig({enabled: true, rollout_basis_points: basisPoints});
for (const userId of syntheticUserIds(200)) {
expect(resolveDomainMigrationAssignment(config, userId).enabled).toBe(enabled);
expect(resolveDomainMigrationAssignment(config, userId, NO_TARGETING).enabled).toBe(enabled);
}
});
@@ -167,13 +171,17 @@ describe('resolveDomainMigrationAssignment', () => {
const salt = DEFAULT_DOMAIN_MIGRATION_CONFIG.rollout_salt;
const bucket = experimentBucket(TARGETED_USER_ID, salt);
expect(
resolveDomainMigrationAssignment(createConfig({enabled: true, rollout_basis_points: bucket}), TARGETED_USER_ID)
.enabled,
resolveDomainMigrationAssignment(
createConfig({enabled: true, rollout_basis_points: bucket}),
TARGETED_USER_ID,
NO_TARGETING,
).enabled,
).toBe(false);
expect(
resolveDomainMigrationAssignment(
createConfig({enabled: true, rollout_basis_points: bucket + 1}),
TARGETED_USER_ID,
NO_TARGETING,
).enabled,
).toBe(true);
});
@@ -232,3 +240,53 @@ describe('toDomainMigrationDiscovery', () => {
});
});
});
describe('resolveDomainMigrationAssignment guild targeting', () => {
const INCLUDED_GUILD_ID = '3000000000000000001';
const MEMBER_GUILDS: ExperimentTargeting = {
memberGuildIds: new Set(['3000000000000000009', INCLUDED_GUILD_ID]),
premium: false,
};
test('serves members of an included guild at zero rollout', () => {
const config = createConfig({enabled: true, included_guild_ids: [INCLUDED_GUILD_ID]});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: true});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
test('keeps user exclusions ahead of guild membership', () => {
const config = createConfig({
enabled: true,
included_guild_ids: [INCLUDED_GUILD_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
});
test('serves no guild members while disabled', () => {
const config = createConfig({included_guild_ids: [INCLUDED_GUILD_ID]});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
});
});
describe('resolveDomainMigrationAssignment premium targeting', () => {
const PREMIUM: ExperimentTargeting = {memberGuildIds: new Set(), premium: true};
test('serves premium users only when the switch is on', () => {
const on = createConfig({enabled: true, include_premium_users: true});
expect(resolveDomainMigrationAssignment(on, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: true});
expect(resolveDomainMigrationAssignment(on, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
expect(resolveDomainMigrationAssignment(createConfig({enabled: true}), TARGETED_USER_ID, PREMIUM)).toEqual({
enabled: false,
});
});
test('keeps user exclusions ahead of the premium switch', () => {
const config = createConfig({
enabled: true,
include_premium_users: true,
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveDomainMigrationAssignment(config, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: false});
});
});
@@ -1,6 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {
EXPERIMENT_BUCKET_RESOLUTION,
type ExperimentTargeting,
experimentAudienceIncludes,
experimentBucket,
} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {z} from 'zod';
const DOMAIN_MIGRATION_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
@@ -20,6 +25,8 @@ const domainMigrationConfigFields = {
rollout_basis_points: z.number().int().min(0).max(DOMAIN_MIGRATION_ROLLOUT_BASIS_POINTS_MAX),
rollout_salt: z.string().trim().min(1).max(64).regex(DOMAIN_MIGRATION_SALT_PATTERN),
included_user_ids: DomainMigrationTargetedUserIdsSchema,
included_guild_ids: DomainMigrationTargetedUserIdsSchema,
include_premium_users: z.boolean(),
excluded_user_ids: DomainMigrationTargetedUserIdsSchema,
anonymous_rollout_basis_points: z.number().int().min(0).max(DOMAIN_MIGRATION_ROLLOUT_BASIS_POINTS_MAX),
standalone_forwarding: z.boolean(),
@@ -31,6 +38,8 @@ export const DomainMigrationConfigSchema = z.object({
rollout_basis_points: domainMigrationConfigFields.rollout_basis_points.default(0),
rollout_salt: domainMigrationConfigFields.rollout_salt.default(DEFAULT_DOMAIN_MIGRATION_SALT),
included_user_ids: domainMigrationConfigFields.included_user_ids.default([]),
included_guild_ids: domainMigrationConfigFields.included_guild_ids.default([]),
include_premium_users: domainMigrationConfigFields.include_premium_users.default(false),
excluded_user_ids: domainMigrationConfigFields.excluded_user_ids.default([]),
anonymous_rollout_basis_points: domainMigrationConfigFields.anonymous_rollout_basis_points.default(0),
standalone_forwarding: domainMigrationConfigFields.standalone_forwarding.default(false),
@@ -64,10 +73,12 @@ export const INERT_DOMAIN_MIGRATION_ASSIGNMENT: DomainMigrationAssignmentRespons
export function resolveDomainMigrationAssignment(
config: DomainMigrationConfig,
userId: string,
targeting: ExperimentTargeting,
): DomainMigrationAssignmentResponse {
if (!config.enabled) return {...INERT_DOMAIN_MIGRATION_ASSIGNMENT};
if (config.excluded_user_ids.includes(userId)) return {...INERT_DOMAIN_MIGRATION_ASSIGNMENT};
if (config.included_user_ids.includes(userId)) return {enabled: true};
if (experimentAudienceIncludes(config, targeting)) return {enabled: true};
return {enabled: experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points};
}
@@ -7,9 +7,11 @@ import {
ProfileTimezoneConfigUpdateRequest,
resolveProfileTimezoneAssignment,
} from '@fluxer/schema/src/domains/admin/ProfileTimezoneSchemas';
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {type ExperimentTargeting, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {describe, expect, test} from 'vitest';
const NO_TARGETING: ExperimentTargeting = {memberGuildIds: new Set(), premium: false};
const TARGETED_USER_ID = '1000000000000000001';
function createConfig(overrides: Partial<ProfileTimezoneConfig> = {}): ProfileTimezoneConfig {
@@ -29,6 +31,8 @@ describe('profile timezone configuration', () => {
rollout_salt: 'profile-timezone-v1',
included_user_ids: [],
excluded_user_ids: [],
included_guild_ids: [],
include_premium_users: false,
});
});
@@ -41,7 +45,7 @@ describe('profile timezone configuration', () => {
describe('resolveProfileTimezoneAssignment', () => {
test('serves nobody while disabled, even included users', () => {
const config = createConfig({rollout_basis_points: 10000, included_user_ids: [TARGETED_USER_ID]});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
test('applies exclusions before inclusions', () => {
@@ -50,20 +54,70 @@ describe('resolveProfileTimezoneAssignment', () => {
included_user_ids: [TARGETED_USER_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID)).toEqual({enabled: false});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
test('serves included users at zero rollout', () => {
const config = createConfig({enabled: true, included_user_ids: [TARGETED_USER_ID]});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID)).toEqual({enabled: true});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: true});
});
test('buckets the rollout by salt and user id', () => {
const config = createConfig({enabled: true, rollout_basis_points: 2500});
for (const userId of syntheticUserIds(200)) {
expect(resolveProfileTimezoneAssignment(config, userId).enabled).toBe(
expect(resolveProfileTimezoneAssignment(config, userId, NO_TARGETING).enabled).toBe(
experimentBucket(userId, config.rollout_salt) < 2500,
);
}
});
});
describe('resolveProfileTimezoneAssignment guild targeting', () => {
const INCLUDED_GUILD_ID = '3000000000000000001';
const MEMBER_GUILDS: ExperimentTargeting = {
memberGuildIds: new Set(['3000000000000000009', INCLUDED_GUILD_ID]),
premium: false,
};
test('serves members of an included guild at zero rollout', () => {
const config = createConfig({enabled: true, included_guild_ids: [INCLUDED_GUILD_ID]});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: true});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
});
test('keeps user exclusions ahead of guild membership', () => {
const config = createConfig({
enabled: true,
included_guild_ids: [INCLUDED_GUILD_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
});
test('serves no guild members while disabled', () => {
const config = createConfig({included_guild_ids: [INCLUDED_GUILD_ID]});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS)).toEqual({enabled: false});
});
});
describe('resolveProfileTimezoneAssignment premium targeting', () => {
const PREMIUM: ExperimentTargeting = {memberGuildIds: new Set(), premium: true};
test('serves premium users only when the switch is on', () => {
const on = createConfig({enabled: true, include_premium_users: true});
expect(resolveProfileTimezoneAssignment(on, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: true});
expect(resolveProfileTimezoneAssignment(on, TARGETED_USER_ID, NO_TARGETING)).toEqual({enabled: false});
expect(resolveProfileTimezoneAssignment(createConfig({enabled: true}), TARGETED_USER_ID, PREMIUM)).toEqual({
enabled: false,
});
});
test('keeps user exclusions ahead of the premium switch', () => {
const config = createConfig({
enabled: true,
include_premium_users: true,
excluded_user_ids: [TARGETED_USER_ID],
});
expect(resolveProfileTimezoneAssignment(config, TARGETED_USER_ID, PREMIUM)).toEqual({enabled: false});
});
});
@@ -1,6 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {
EXPERIMENT_BUCKET_RESOLUTION,
type ExperimentTargeting,
experimentAudienceIncludes,
experimentBucket,
} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {z} from 'zod';
const PROFILE_TIMEZONE_ROLLOUT_BASIS_POINTS_MAX = EXPERIMENT_BUCKET_RESOLUTION;
@@ -20,6 +25,8 @@ const profileTimezoneConfigFields = {
rollout_basis_points: z.number().int().min(0).max(PROFILE_TIMEZONE_ROLLOUT_BASIS_POINTS_MAX),
rollout_salt: z.string().trim().min(1).max(64).regex(PROFILE_TIMEZONE_SALT_PATTERN),
included_user_ids: ProfileTimezoneTargetedUserIdsSchema,
included_guild_ids: ProfileTimezoneTargetedUserIdsSchema,
include_premium_users: z.boolean(),
excluded_user_ids: ProfileTimezoneTargetedUserIdsSchema,
};
@@ -29,6 +36,8 @@ export const ProfileTimezoneConfigSchema = z.object({
rollout_basis_points: profileTimezoneConfigFields.rollout_basis_points.default(0),
rollout_salt: profileTimezoneConfigFields.rollout_salt.default(DEFAULT_PROFILE_TIMEZONE_SALT),
included_user_ids: profileTimezoneConfigFields.included_user_ids.default([]),
included_guild_ids: profileTimezoneConfigFields.included_guild_ids.default([]),
include_premium_users: profileTimezoneConfigFields.include_premium_users.default(false),
excluded_user_ids: profileTimezoneConfigFields.excluded_user_ids.default([]),
});
@@ -60,9 +69,11 @@ export const INERT_PROFILE_TIMEZONE_ASSIGNMENT: ProfileTimezoneAssignmentRespons
export function resolveProfileTimezoneAssignment(
config: ProfileTimezoneConfig,
userId: string,
targeting: ExperimentTargeting,
): ProfileTimezoneAssignmentResponse {
if (!config.enabled) return {...INERT_PROFILE_TIMEZONE_ASSIGNMENT};
if (config.excluded_user_ids.includes(userId)) return {...INERT_PROFILE_TIMEZONE_ASSIGNMENT};
if (config.included_user_ids.includes(userId)) return {enabled: true};
if (experimentAudienceIncludes(config, targeting)) return {enabled: true};
return {enabled: experimentBucket(userId, config.rollout_salt) < config.rollout_basis_points};
}
@@ -11,9 +11,11 @@ import {
VoiceNoiseSuppressionConfigSchema,
VoiceNoiseSuppressionConfigUpdateRequest,
} from '@fluxer/schema/src/domains/admin/VoiceNoiseSuppressionSchemas';
import {experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {type ExperimentTargeting, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {describe, expect, test} from 'vitest';
const NO_TARGETING: ExperimentTargeting = {memberGuildIds: new Set(), premium: false};
const TARGETED_USER_ID = '1000000000000000001';
const OTHER_USER_ID = '1000000000000000002';
const GUILD_ID = '2000000000000000001';
@@ -95,7 +97,7 @@ function syntheticUserIds(count: number): Array<string> {
function targetedUserIds(config: VoiceNoiseSuppressionConfig, userIds: ReadonlyArray<string>): Set<string> {
const targeted = new Set<string>();
for (const userId of userIds) {
if (resolveVoiceNoiseSuppressionAssignment(config, userId).user_targeted) {
if (resolveVoiceNoiseSuppressionAssignment(config, userId, NO_TARGETING).user_targeted) {
targeted.add(userId);
}
}
@@ -139,14 +141,14 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
included_user_ids: [TARGETED_USER_ID],
guild_overrides: [{guild_id: GUILD_ID, backend: 'rnnoise'}],
});
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID)).toEqual({
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING)).toEqual({
...INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
config_version: 4,
});
});
test('returns the inert assignment for the default config', () => {
expect(resolveVoiceNoiseSuppressionAssignment(createConfig(), TARGETED_USER_ID)).toEqual(
expect(resolveVoiceNoiseSuppressionAssignment(createConfig(), TARGETED_USER_ID, NO_TARGETING)).toEqual(
INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT,
);
});
@@ -157,7 +159,7 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
included_user_ids: [TARGETED_USER_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID);
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING);
expect(assignment.user_targeted).toBe(false);
expect(assignment.backend).toBeNull();
expect(assignment.source).toBeNull();
@@ -169,10 +171,10 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
rollout_basis_points: 10000,
excluded_user_ids: [TARGETED_USER_ID],
});
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID);
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING);
expect(assignment.user_targeted).toBe(false);
expect(assignment.source).toBeNull();
expect(resolveVoiceNoiseSuppressionAssignment(config, OTHER_USER_ID).user_targeted).toBe(true);
expect(resolveVoiceNoiseSuppressionAssignment(config, OTHER_USER_ID, NO_TARGETING).user_targeted).toBe(true);
});
test('denylist strips guild overrides and the client-side knobs', () => {
@@ -183,7 +185,7 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
excluded_user_ids: [TARGETED_USER_ID],
guild_overrides: [{guild_id: GUILD_ID, backend: 'rnnoise'}],
});
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID);
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING);
expect(assignment.guild_overrides).toEqual([]);
expect(assignment.enabled_backends).toEqual([]);
expect(assignment.allow_user_override).toBe(false);
@@ -196,7 +198,7 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
rollout_basis_points: 0,
included_user_ids: [TARGETED_USER_ID],
});
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID);
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING);
expect(assignment.user_targeted).toBe(true);
expect(assignment.backend).toBe('gtcrn');
expect(assignment.source).toBe('user_rule');
@@ -208,7 +210,7 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
])('canary at $basisPoints basis points targets $targeted', ({basisPoints, targeted}) => {
const config = createConfig({enabled: true, rollout_basis_points: basisPoints});
for (const userId of syntheticUserIds(200)) {
const assignment = resolveVoiceNoiseSuppressionAssignment(config, userId);
const assignment = resolveVoiceNoiseSuppressionAssignment(config, userId, NO_TARGETING);
expect(assignment.user_targeted).toBe(targeted);
expect(assignment.source).toBe(targeted ? 'canary' : null);
}
@@ -247,7 +249,7 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
included_user_ids: [OTHER_USER_ID],
});
for (const userId of [TARGETED_USER_ID, OTHER_USER_ID]) {
const assignment = resolveVoiceNoiseSuppressionAssignment(config, userId);
const assignment = resolveVoiceNoiseSuppressionAssignment(config, userId, NO_TARGETING);
expect(assignment.user_targeted).toBe(false);
expect(assignment.backend).toBeNull();
expect(assignment.source).toBeNull();
@@ -265,7 +267,7 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
{guild_id: OTHER_GUILD_ID, backend: 'deep_filter'},
],
});
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID).guild_overrides).toEqual([
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING).guild_overrides).toEqual([
{guild_id: GUILD_ID, backend: 'rnnoise'},
]);
});
@@ -280,14 +282,14 @@ describe('resolveVoiceNoiseSuppressionAssignment', () => {
{guild_id: OTHER_GUILD_ID, backend: 'gtcrn'},
],
});
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID).guild_overrides).toEqual([
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING).guild_overrides).toEqual([
{guild_id: GUILD_ID, backend: 'rnnoise'},
]);
});
test('echoes the config version on every path', () => {
const config = createConfig({enabled: true, config_version: 11, rollout_basis_points: 10000});
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID).config_version).toBe(11);
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING).config_version).toBe(11);
});
});
@@ -412,3 +414,46 @@ describe('resolveVoiceNoiseSuppressionForCall', () => {
});
});
});
describe('resolveVoiceNoiseSuppressionAssignment guild targeting', () => {
const INCLUDED_GUILD_ID = '3000000000000000001';
const MEMBER_GUILDS: ExperimentTargeting = {
memberGuildIds: new Set(['3000000000000000009', INCLUDED_GUILD_ID]),
premium: false,
};
test('targets members of an included guild the way it targets included users', () => {
const config = createConfig({enabled: true, included_guild_ids: [INCLUDED_GUILD_ID]});
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS);
expect(assignment.user_targeted).toBe(true);
expect(assignment.source).toBe('user_rule');
expect(assignment.backend).toBe(config.default_backend);
expect(resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, NO_TARGETING).user_targeted).toBe(false);
});
test('keeps user exclusions ahead of guild membership', () => {
const config = createConfig({
enabled: true,
included_guild_ids: [INCLUDED_GUILD_ID],
excluded_user_ids: [TARGETED_USER_ID],
});
const assignment = resolveVoiceNoiseSuppressionAssignment(config, TARGETED_USER_ID, MEMBER_GUILDS);
expect(assignment.user_targeted).toBe(false);
expect(assignment.source).toBeNull();
});
});
describe('resolveVoiceNoiseSuppressionAssignment premium targeting', () => {
const PREMIUM: ExperimentTargeting = {memberGuildIds: new Set(), premium: true};
test('targets premium users only when the switch is on', () => {
const on = createConfig({enabled: true, include_premium_users: true});
expect(resolveVoiceNoiseSuppressionAssignment(on, TARGETED_USER_ID, PREMIUM)).toMatchObject({
user_targeted: true,
source: 'user_rule',
});
expect(resolveVoiceNoiseSuppressionAssignment(on, TARGETED_USER_ID, NO_TARGETING).user_targeted).toBe(false);
const off = createConfig({enabled: true});
expect(resolveVoiceNoiseSuppressionAssignment(off, TARGETED_USER_ID, PREMIUM).user_targeted).toBe(false);
});
});
@@ -1,6 +1,11 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
import {EXPERIMENT_BUCKET_RESOLUTION, experimentBucket} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {
EXPERIMENT_BUCKET_RESOLUTION,
type ExperimentTargeting,
experimentAudienceIncludes,
experimentBucket,
} from '@fluxer/schema/src/domains/experiment/ExperimentBucket';
import {z} from 'zod';
export const VOICE_NOISE_SUPPRESSION_BACKENDS = [
@@ -39,6 +44,8 @@ const voiceConfigFields = {
rollout_basis_points: z.number().int().min(0).max(VOICE_NOISE_SUPPRESSION_ROLLOUT_BASIS_POINTS_MAX),
rollout_salt: z.string().trim().min(1).max(64),
included_user_ids: TargetedUserIdsSchema,
included_guild_ids: TargetedUserIdsSchema,
include_premium_users: z.boolean(),
excluded_user_ids: TargetedUserIdsSchema,
guild_overrides: z.array(VoiceNoiseSuppressionGuildOverrideSchema).max(VOICE_NOISE_SUPPRESSION_MAX_GUILD_OVERRIDES),
suppression_strength: z.number().int().min(0).max(100),
@@ -53,6 +60,8 @@ export const VoiceNoiseSuppressionConfigSchema = z.object({
rollout_basis_points: voiceConfigFields.rollout_basis_points.default(0),
rollout_salt: voiceConfigFields.rollout_salt.default(DEFAULT_VOICE_NOISE_SUPPRESSION_SALT),
included_user_ids: voiceConfigFields.included_user_ids.default([]),
included_guild_ids: voiceConfigFields.included_guild_ids.default([]),
include_premium_users: voiceConfigFields.include_premium_users.default(false),
excluded_user_ids: voiceConfigFields.excluded_user_ids.default([]),
guild_overrides: voiceConfigFields.guild_overrides.default([]),
suppression_strength: voiceConfigFields.suppression_strength.default(80),
@@ -107,6 +116,7 @@ export const INERT_VOICE_NOISE_SUPPRESSION_ASSIGNMENT: VoiceNoiseSuppressionAssi
export function resolveVoiceNoiseSuppressionAssignment(
config: VoiceNoiseSuppressionConfig,
userId: string,
targeting: ExperimentTargeting,
): VoiceNoiseSuppressionAssignmentResponse {
if (!config.enabled) {
return {
@@ -136,7 +146,7 @@ export function resolveVoiceNoiseSuppressionAssignment(
const guildOverrides = config.guild_overrides.filter((override) =>
config.enabled_backends.includes(override.backend),
);
if (config.included_user_ids.includes(userId)) {
if (config.included_user_ids.includes(userId) || experimentAudienceIncludes(config, targeting)) {
return {
...shared,
user_targeted: backendIsUsable,
@@ -14,3 +14,18 @@ export function experimentBucket(userId: string, salt: string): number {
}
return hash % EXPERIMENT_BUCKET_RESOLUTION;
}
export interface ExperimentTargeting {
readonly memberGuildIds: ReadonlySet<string>;
readonly premium: boolean;
}
interface ExperimentAudienceRules {
readonly included_guild_ids: ReadonlyArray<string>;
readonly include_premium_users: boolean;
}
export function experimentAudienceIncludes(rules: ExperimentAudienceRules, targeting: ExperimentTargeting): boolean {
if (rules.include_premium_users && targeting.premium) return true;
return rules.included_guild_ids.some((guildId) => targeting.memberGuildIds.has(guildId));
}