Skip to content

Commit a810ce4

Browse files
committed
[AI-FSSDK] [FSSDK-12369] Add local holdouts support to JavaScript SDK
1 parent 6c0a6e9 commit a810ce4

5 files changed

Lines changed: 463 additions & 6 deletions

File tree

lib/core/decision_service/index.spec.ts

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2879,4 +2879,190 @@ describe('DecisionService', () => {
28792879
expect(variation).toBe(null);
28802880
});
28812881
});
2882+
2883+
// Level 2 decision service tests for local holdouts (FSSDK-12369)
2884+
// One test per branch of the pseudocode in Step 3 of the ticket.
2885+
describe('local holdouts (FSSDK-12369)', () => {
2886+
// Helper: build a datafile that has a local holdout targeting a specific experiment or delivery rule.
2887+
const makeLocalHoldoutDatafile = (targetRuleId: string, ruleIds: string[] = [targetRuleId]) => {
2888+
const datafile = getDecisionTestDatafile();
2889+
(datafile as any).holdouts = [
2890+
{
2891+
id: 'local_holdout_id',
2892+
key: 'local_holdout',
2893+
status: 'Running',
2894+
includedFlags: [],
2895+
excludedFlags: [],
2896+
includedRules: ruleIds,
2897+
audienceIds: [],
2898+
audienceConditions: [],
2899+
variations: [
2900+
{
2901+
id: 'local_holdout_variation_id',
2902+
key: 'local_holdout_variation',
2903+
variables: []
2904+
}
2905+
],
2906+
trafficAllocation: [
2907+
{ entityId: 'local_holdout_variation_id', endOfRange: 10000 }
2908+
]
2909+
}
2910+
];
2911+
return datafile;
2912+
};
2913+
2914+
beforeEach(() => {
2915+
mockBucket.mockReset();
2916+
});
2917+
2918+
it('global holdout branch: global holdout is evaluated before per-rule logic', async () => {
2919+
const datafile = getDecisionTestDatafile();
2920+
(datafile as any).holdouts = [
2921+
{
2922+
id: 'global_holdout_id',
2923+
key: 'global_holdout',
2924+
status: 'Running',
2925+
includedFlags: [],
2926+
excludedFlags: [],
2927+
// No includedRules → global holdout
2928+
audienceIds: [],
2929+
audienceConditions: [],
2930+
variations: [
2931+
{ id: 'global_holdout_var_id', key: 'global_holdout_var', variables: [] }
2932+
],
2933+
trafficAllocation: [{ entityId: 'global_holdout_var_id', endOfRange: 10000 }]
2934+
}
2935+
];
2936+
const config = createProjectConfig(datafile);
2937+
const { decisionService } = getDecisionService();
2938+
2939+
// bucket returns the global holdout variation for the holdout, nothing for experiments
2940+
mockBucket.mockImplementation((params: BucketerParams) => {
2941+
if (params.experimentId === 'global_holdout_id') {
2942+
return { result: 'global_holdout_var_id', reasons: [] };
2943+
}
2944+
return { result: null, reasons: [] };
2945+
});
2946+
2947+
const user = new OptimizelyUserContext({ optimizely: {} as any, userId: 'user1' });
2948+
const feature = config.featureKeyMap['flag_1'];
2949+
const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
2950+
2951+
// Decision should be from the global holdout, not from any experiment
2952+
expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.HOLDOUT);
2953+
expect(value[0].result.experiment?.id).toBe('global_holdout_id');
2954+
});
2955+
2956+
it('local holdout hit branch: user bucketed into local holdout for experiment rule returns holdout variation; audience and traffic not evaluated for that rule', async () => {
2957+
// exp_1 has id '2001'
2958+
const config = createProjectConfig(makeLocalHoldoutDatafile('2001'));
2959+
const { decisionService } = getDecisionService();
2960+
2961+
// bucket returns holdout variation when evaluating the local holdout
2962+
mockBucket.mockImplementation((params: BucketerParams) => {
2963+
if (params.experimentId === 'local_holdout_id') {
2964+
return { result: 'local_holdout_variation_id', reasons: [] };
2965+
}
2966+
return { result: null, reasons: [] };
2967+
});
2968+
2969+
const user = new OptimizelyUserContext({ optimizely: {} as any, userId: 'user1' });
2970+
const feature = config.featureKeyMap['flag_1'];
2971+
const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
2972+
2973+
// Should return holdout decision for the local holdout
2974+
expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.HOLDOUT);
2975+
expect(value[0].result.experiment?.id).toBe('local_holdout_id');
2976+
expect(value[0].result.variation?.id).toBe('local_holdout_variation_id');
2977+
});
2978+
2979+
it('local holdout miss branch: user not bucketed into local holdout falls through to regular rule evaluation', async () => {
2980+
// exp_1 has id '2001' and audience 4001 (age <= 22)
2981+
const config = createProjectConfig(makeLocalHoldoutDatafile('2001'));
2982+
const { decisionService } = getDecisionService();
2983+
2984+
// bucket returns null for the local holdout, then succeeds for the experiment
2985+
mockBucket.mockImplementation((params: BucketerParams) => {
2986+
if (params.experimentId === 'local_holdout_id') {
2987+
return { result: null, reasons: [] };
2988+
}
2989+
if (params.experimentId === '2001') {
2990+
return { result: '5001', reasons: [] }; // variation_1 in exp_1
2991+
}
2992+
return { result: null, reasons: [] };
2993+
});
2994+
2995+
const user = new OptimizelyUserContext({
2996+
optimizely: {} as any,
2997+
userId: 'user1',
2998+
attributes: { age: 15 }, // satisfies 4001 audience (age <= 22)
2999+
});
3000+
const feature = config.featureKeyMap['flag_1'];
3001+
const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
3002+
3003+
// Should fall through to experiment evaluation (not holdout)
3004+
expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.FEATURE_TEST);
3005+
expect(value[0].result.variation?.id).toBe('5001');
3006+
});
3007+
3008+
it('rule specificity: local holdout targeting experiment rule X does not affect experiment rule Y', async () => {
3009+
// exp_1 = '2001', exp_2 = '2002'. Local holdout targets only '2002' (exp_2).
3010+
// Audience for exp_1: 4001 (age <= 22). User satisfies exp_1 audience but not exp_2.
3011+
const config = createProjectConfig(makeLocalHoldoutDatafile('2002'));
3012+
const { decisionService } = getDecisionService();
3013+
3014+
// bucket returns holdout variation only for the local holdout when evaluating for '2002',
3015+
// and returns experiment variation for '2001'
3016+
mockBucket.mockImplementation((params: BucketerParams) => {
3017+
if (params.experimentId === 'local_holdout_id') {
3018+
return { result: 'local_holdout_variation_id', reasons: [] };
3019+
}
3020+
if (params.experimentId === '2001') {
3021+
return { result: '5001', reasons: [] };
3022+
}
3023+
return { result: null, reasons: [] };
3024+
});
3025+
3026+
// User satisfies exp_1 audience (age <= 22)
3027+
const user = new OptimizelyUserContext({
3028+
optimizely: {} as any,
3029+
userId: 'user1',
3030+
attributes: { age: 15 },
3031+
});
3032+
const feature = config.featureKeyMap['flag_1'];
3033+
const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
3034+
3035+
// exp_1 is evaluated first; local holdout targets '2002' not '2001', so exp_1 is evaluated normally
3036+
expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.FEATURE_TEST);
3037+
expect(value[0].result.experiment?.id).toBe('2001');
3038+
});
3039+
3040+
it('local holdout applies to delivery rules (rollouts) as well as experiment rules', async () => {
3041+
// delivery_1 has id '3001'
3042+
const config = createProjectConfig(makeLocalHoldoutDatafile('3001'));
3043+
const { decisionService } = getDecisionService();
3044+
3045+
// bucket returns null for all experiments and the local holdout variation for delivery rule
3046+
mockBucket.mockImplementation((params: BucketerParams) => {
3047+
if (params.experimentId === 'local_holdout_id') {
3048+
return { result: 'local_holdout_variation_id', reasons: [] };
3049+
}
3050+
return { result: null, reasons: [] };
3051+
});
3052+
3053+
// No audience attributes → experiments won't match, falls through to rollout
3054+
const user = new OptimizelyUserContext({
3055+
optimizely: {} as any,
3056+
userId: 'user1',
3057+
attributes: { age: 15 }, // satisfies 4001 used by delivery_1
3058+
});
3059+
const feature = config.featureKeyMap['flag_1'];
3060+
const value = await decisionService.resolveVariationsForFeatureList('async', config, [feature], user, {}).get();
3061+
3062+
// Should be a holdout decision from the local holdout targeting the delivery rule
3063+
expect(value[0].result.decisionSource).toBe(DECISION_SOURCES.HOLDOUT);
3064+
expect(value[0].result.experiment?.id).toBe('local_holdout_id');
3065+
});
3066+
});
28823067
});
3068+

lib/core/decision_service/index.ts

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,8 @@ import {
2929
getVariationIdFromExperimentAndVariationKey,
3030
getVariationFromId,
3131
getVariationKeyFromId,
32+
getGlobalHoldouts,
33+
getHoldoutsForRule,
3234
isActive,
3335
ProjectConfig,
3436
} from '../../project_config/project_config';
@@ -135,6 +137,8 @@ interface DecisionServiceOptions {
135137

136138
interface DeliveryRuleResponse<T, K> extends DecisionResponse<T> {
137139
skipToEveryoneElse: K;
140+
/** Set when a local holdout was hit for this delivery rule (FSSDK-12369). */
141+
localHoldoutDecision?: DecisionObj;
138142
}
139143

140144
interface UserProfileTracker {
@@ -145,6 +149,12 @@ interface UserProfileTracker {
145149
type VarationKeyWithCmabParams = {
146150
variationKey?: string;
147151
cmabUuid?: string;
152+
/**
153+
* Set when the variation comes from a local holdout (FSSDK-12369).
154+
* When present, the caller should use this as the full decision result
155+
* instead of constructing one from the experiment.
156+
*/
157+
localHoldoutDecision?: DecisionObj;
148158
};
149159
export type DecisionReason = [string, ...any[]];
150160
export type VariationResult = DecisionResponse<VarationKeyWithCmabParams>;
@@ -943,11 +953,11 @@ export class DecisionService {
943953
});
944954
}
945955

946-
// all global holouts should be evaluated for all flags
947-
// global holdouts are available in configObj.holdouts
948-
const { holdouts } = configObj;
956+
// Global holdouts are evaluated at flag level, before any per-rule logic.
957+
// getGlobalHoldouts() returns holdouts with includedRules == null/undefined.
958+
const globalHoldouts = getGlobalHoldouts(configObj);
949959

950-
for (const holdout of holdouts) {
960+
for (const holdout of globalHoldouts) {
951961
const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user);
952962
decideReasons.push(...holdoutDecision.reasons);
953963

@@ -1092,11 +1102,19 @@ export class DecisionService {
10921102
});
10931103
}
10941104

1105+
// If a local holdout was hit, return the holdout decision directly (preserves holdout as experiment).
1106+
if (decisionVariation.result.localHoldoutDecision) {
1107+
return Value.of(op, {
1108+
result: decisionVariation.result.localHoldoutDecision,
1109+
reasons: decideReasons,
1110+
});
1111+
}
1112+
10951113
if(!decisionVariation.result.variationKey) {
10961114
return this.traverseFeatureExperimentList(
10971115
op, configObj, feature, fromIndex + 1, user, decideReasons, decideOptions, userProfileTracker);
10981116
}
1099-
1117+
11001118
const variationKey = decisionVariation.result.variationKey;
11011119
let variation: Variation | null = experiment.variationKeyMap[variationKey];
11021120
if (!variation) {
@@ -1184,6 +1202,13 @@ export class DecisionService {
11841202
variation = decisionVariation.result;
11851203
skipToEveryoneElse = decisionVariation.skipToEveryoneElse;
11861204
if (variation) {
1205+
// If a local holdout was hit for this delivery rule, use its decision object directly.
1206+
if (decisionVariation.localHoldoutDecision) {
1207+
return {
1208+
result: decisionVariation.localHoldoutDecision,
1209+
reasons: decideReasons,
1210+
};
1211+
}
11871212
rolloutRule = configObj.experimentIdMap[rolloutRules[index].id];
11881213
decisionObj = {
11891214
experiment: rolloutRule,
@@ -1562,6 +1587,25 @@ export class DecisionService {
15621587
reasons: decideReasons,
15631588
});
15641589
}
1590+
1591+
// Check local holdouts targeting this specific experiment rule (FSSDK-12369).
1592+
// Inserted immediately after the forced-decision block, before regular rule evaluation.
1593+
const localHoldoutsForExperiment = getHoldoutsForRule(configObj, rule.id);
1594+
for (const holdout of localHoldoutsForExperiment) {
1595+
const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user);
1596+
decideReasons.push(...holdoutDecision.reasons);
1597+
if (holdoutDecision.result.variation) {
1598+
// Signal the caller to use the holdout decision directly, preserving the holdout as experiment.
1599+
return Value.of(op, {
1600+
result: {
1601+
variationKey: holdoutDecision.result.variation.key,
1602+
localHoldoutDecision: holdoutDecision.result,
1603+
},
1604+
reasons: decideReasons,
1605+
});
1606+
}
1607+
}
1608+
15651609
const decisionVariationValue = this.resolveVariation(op, configObj, rule, user, decideOptions, userProfileTracker);
15661610

15671611
return decisionVariationValue.then((variationResult) => {
@@ -1608,6 +1652,22 @@ export class DecisionService {
16081652
};
16091653
}
16101654

1655+
// Check local holdouts targeting this specific delivery rule (FSSDK-12369).
1656+
// Inserted immediately after the forced-decision block, before audience and traffic allocation checks.
1657+
const localHoldoutsForDelivery = getHoldoutsForRule(configObj, rule.id);
1658+
for (const holdout of localHoldoutsForDelivery) {
1659+
const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user);
1660+
decideReasons.push(...holdoutDecision.reasons);
1661+
if (holdoutDecision.result.variation) {
1662+
return {
1663+
result: holdoutDecision.result.variation,
1664+
reasons: decideReasons,
1665+
skipToEveryoneElse,
1666+
localHoldoutDecision: holdoutDecision.result,
1667+
};
1668+
}
1669+
}
1670+
16111671
const userId = user.getUserId();
16121672
const attributes = user.getAttributes();
16131673
const bucketingId = this.getBucketingId(userId, attributes);

0 commit comments

Comments
 (0)