Skip to content

Commit f8f9ea4

Browse files
Mat001claude
andcommitted
[AI-FSSDK] [FSSDK-12369] Add local holdouts support with includedRules field
- Add optional includedRules field to Holdout interface (null/undefined = global, array = local) - Add globalHoldouts and ruleHoldoutsMap to ProjectConfig type - Update parseHoldoutsConfig to separate global vs local holdouts based on includedRules - Add getHoldoutsForRule() helper exported from project_config - Update decision_service to use globalHoldouts at flag level (replaces configObj.holdouts) - Add local holdout checks in getVariationFromExperimentRule (per experiment rule) - Add local holdout checks in getVariationFromDeliveryRule (per delivery/rollout rule) - Add 6 new tests in project_config.spec.ts covering global/local classification and backward compat Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 6c0a6e9 commit f8f9ea4

4 files changed

Lines changed: 141 additions & 4 deletions

File tree

lib/core/decision_service/index.ts

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ import {
2929
getVariationIdFromExperimentAndVariationKey,
3030
getVariationFromId,
3131
getVariationKeyFromId,
32+
getHoldoutsForRule,
3233
isActive,
3334
ProjectConfig,
3435
} from '../../project_config/project_config';
@@ -943,11 +944,11 @@ export class DecisionService {
943944
});
944945
}
945946

946-
// all global holouts should be evaluated for all flags
947-
// global holdouts are available in configObj.holdouts
948-
const { holdouts } = configObj;
947+
// Check global holdouts first (holdouts where includedRules == null).
948+
// Global holdouts apply to all rules and are evaluated at the flag level.
949+
const globalHoldouts = configObj.globalHoldouts || [];
949950

950-
for (const holdout of holdouts) {
951+
for (const holdout of globalHoldouts) {
951952
const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user);
952953
decideReasons.push(...holdoutDecision.reasons);
953954

@@ -1562,6 +1563,21 @@ export class DecisionService {
15621563
reasons: decideReasons,
15631564
});
15641565
}
1566+
1567+
// Check local holdouts targeting this specific experiment rule.
1568+
// Local holdouts are checked after forced decisions but before regular bucketing.
1569+
const localHoldouts = getHoldoutsForRule(configObj, rule.id);
1570+
for (const holdout of localHoldouts) {
1571+
const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user);
1572+
decideReasons.push(...holdoutDecision.reasons);
1573+
if (holdoutDecision.result.variation) {
1574+
return Value.of(op, {
1575+
result: { variationKey: holdoutDecision.result.variation.key },
1576+
reasons: decideReasons,
1577+
});
1578+
}
1579+
}
1580+
15651581
const decisionVariationValue = this.resolveVariation(op, configObj, rule, user, decideOptions, userProfileTracker);
15661582

15671583
return decisionVariationValue.then((variationResult) => {
@@ -1608,6 +1624,21 @@ export class DecisionService {
16081624
};
16091625
}
16101626

1627+
// Check local holdouts targeting this specific delivery rule.
1628+
// Local holdouts are checked after forced decisions but before audience/bucketing evaluation.
1629+
const localHoldouts = getHoldoutsForRule(configObj, rule.id);
1630+
for (const holdout of localHoldouts) {
1631+
const holdoutDecision = this.getVariationForHoldout(configObj, holdout, user);
1632+
decideReasons.push(...holdoutDecision.reasons);
1633+
if (holdoutDecision.result.variation) {
1634+
return {
1635+
result: holdoutDecision.result.variation,
1636+
reasons: decideReasons,
1637+
skipToEveryoneElse,
1638+
};
1639+
}
1640+
}
1641+
16111642
const userId = user.getUserId();
16121643
const attributes = user.getAttributes();
16131644
const bucketingId = this.getBucketingId(userId, attributes);

lib/project_config/project_config.spec.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -414,6 +414,77 @@ describe('createProjectConfig - holdouts', () => {
414414
expect(configObj.holdouts[0].includedFlags).toEqual([]);
415415
expect(configObj.holdouts[0].excludedFlags).toEqual([]);
416416
});
417+
418+
it('should populate globalHoldouts with holdouts where includedRules is null or undefined', function() {
419+
const datafile = getHoldoutDatafile();
420+
// holdout_1, holdout_2, holdout_3 all have no includedRules (undefined = global)
421+
const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile)));
422+
423+
expect(configObj.globalHoldouts).toHaveLength(3);
424+
expect(configObj.ruleHoldoutsMap).toEqual({});
425+
});
426+
427+
it('should classify holdout as local when includedRules is an array', function() {
428+
const datafile = getHoldoutDatafile();
429+
datafile.holdouts[0].includedRules = ['rule_id_1', 'rule_id_2'];
430+
431+
const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile)));
432+
433+
// holdout_1 is now local (has includedRules array)
434+
expect(configObj.globalHoldouts).toHaveLength(2); // holdout_2, holdout_3
435+
expect(configObj.ruleHoldoutsMap['rule_id_1']).toHaveLength(1);
436+
expect(configObj.ruleHoldoutsMap['rule_id_2']).toHaveLength(1);
437+
});
438+
439+
it('should classify holdout as local when includedRules is empty array', function() {
440+
const datafile = getHoldoutDatafile();
441+
datafile.holdouts[0].includedRules = []; // empty array = local, targets no rules
442+
443+
const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile)));
444+
445+
// holdout_1 is local but has no rules to target
446+
expect(configObj.globalHoldouts).toHaveLength(2); // holdout_2, holdout_3
447+
expect(configObj.ruleHoldoutsMap).toEqual({});
448+
});
449+
450+
it('should support multiple holdouts targeting the same rule', function() {
451+
const datafile = getHoldoutDatafile();
452+
datafile.holdouts[0].includedRules = ['rule_shared'];
453+
datafile.holdouts[1].includedRules = ['rule_shared'];
454+
455+
const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile)));
456+
457+
expect(configObj.ruleHoldoutsMap['rule_shared']).toHaveLength(2);
458+
expect(configObj.globalHoldouts).toHaveLength(1); // only holdout_3
459+
});
460+
461+
it('getHoldoutsForRule returns local holdouts for a rule', function() {
462+
const datafile = getHoldoutDatafile();
463+
datafile.holdouts[0].includedRules = ['rule_a'];
464+
datafile.holdouts[1].includedRules = ['rule_a', 'rule_b'];
465+
466+
const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile)));
467+
468+
const ruleAHoldouts = projectConfig.getHoldoutsForRule(configObj, 'rule_a');
469+
expect(ruleAHoldouts).toHaveLength(2);
470+
471+
const ruleBHoldouts = projectConfig.getHoldoutsForRule(configObj, 'rule_b');
472+
expect(ruleBHoldouts).toHaveLength(1);
473+
474+
const ruleCHoldouts = projectConfig.getHoldoutsForRule(configObj, 'rule_c');
475+
expect(ruleCHoldouts).toHaveLength(0);
476+
});
477+
478+
it('backward compatibility: old datafiles without includedRules treat holdouts as global', function() {
479+
const datafile = getHoldoutDatafile();
480+
// Simulating old datafile: holdouts have no includedRules field at all
481+
datafile.holdouts.forEach((h: any) => delete h.includedRules);
482+
483+
const configObj = projectConfig.createProjectConfig(JSON.parse(JSON.stringify(datafile)));
484+
485+
expect(configObj.globalHoldouts).toHaveLength(3);
486+
expect(configObj.ruleHoldoutsMap).toEqual({});
487+
});
417488
});
418489

419490
describe('getExperimentId', () => {

lib/project_config/project_config.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,8 @@ export interface ProjectConfig {
113113
odpIntegrationConfig: OdpIntegrationConfig;
114114
holdouts: Holdout[];
115115
holdoutIdMap?: { [id: string]: Holdout };
116+
globalHoldouts: Holdout[];
117+
ruleHoldoutsMap: { [ruleId: string]: Holdout[] };
116118
}
117119

118120
const EXPERIMENT_RUNNING_STATUS = 'Running';
@@ -386,6 +388,8 @@ const getEveryoneElseVariation = function(
386388
const parseHoldoutsConfig = (projectConfig: ProjectConfig): void => {
387389
projectConfig.holdouts = projectConfig.holdouts || [];
388390
projectConfig.holdoutIdMap = keyBy(projectConfig.holdouts, 'id');
391+
projectConfig.globalHoldouts = [];
392+
projectConfig.ruleHoldoutsMap = {};
389393

390394
projectConfig.holdouts.forEach((holdout) => {
391395

@@ -396,9 +400,33 @@ const parseHoldoutsConfig = (projectConfig: ProjectConfig): void => {
396400
holdout.excludedFlags = [];
397401
holdout.variationKeyMap = keyBy(holdout.variations, 'key');
398402
assignBy(holdout.variations, 'id', projectConfig.variationIdMap);
403+
404+
// Classify holdout as global or local based on includedRules field.
405+
// If includedRules is null or undefined, the holdout is global (applies to all rules).
406+
// If includedRules is an array (even empty), the holdout is local (targets specific rules).
407+
if (holdout.includedRules == null) {
408+
projectConfig.globalHoldouts.push(holdout);
409+
} else {
410+
holdout.includedRules.forEach((ruleId) => {
411+
if (!projectConfig.ruleHoldoutsMap[ruleId]) {
412+
projectConfig.ruleHoldoutsMap[ruleId] = [];
413+
}
414+
projectConfig.ruleHoldoutsMap[ruleId].push(holdout);
415+
});
416+
}
399417
});
400418
}
401419

420+
/**
421+
* Returns holdouts that target a specific rule (local holdouts).
422+
* @param {ProjectConfig} projectConfig - The project config
423+
* @param {string} ruleId - The rule ID to look up
424+
* @returns {Holdout[]} - Array of holdouts targeting this rule
425+
*/
426+
export const getHoldoutsForRule = (projectConfig: ProjectConfig, ruleId: string): Holdout[] => {
427+
return projectConfig.ruleHoldoutsMap[ruleId] || [];
428+
}
429+
402430
/**
403431
* Extract all audience segments used in this audience's conditions
404432
* @param {Audience} audience Object representing the audience being parsed
@@ -959,6 +987,7 @@ export default {
959987
getSendFlagDecisionsValue,
960988
getAudiencesById,
961989
getAudienceSegments,
990+
getHoldoutsForRule,
962991
eventWithKeyExists,
963992
isFeatureExperiment,
964993
toDatafile,

lib/shared_types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,12 @@ export interface Holdout extends ExperimentCore {
176176
status: HoldoutStatus;
177177
includedFlags: string[];
178178
excludedFlags: string[];
179+
/**
180+
* List of rule IDs this holdout targets (local holdouts only).
181+
* If null or undefined, the holdout is global and applies to all rules.
182+
* If an array (even empty), the holdout is local and targets specific rule IDs.
183+
*/
184+
includedRules?: string[] | null;
179185
}
180186

181187
export function isHoldout(obj: Experiment | Holdout): obj is Holdout {

0 commit comments

Comments
 (0)