Skip to content

Commit 6716d90

Browse files
committed
[AI-FSSDK] [FSSDK-12369] Add local holdouts support to Python SDK
1 parent 807d75b commit 6716d90

6 files changed

Lines changed: 716 additions & 18 deletions

File tree

optimizely/decision_service.py

Lines changed: 44 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -610,6 +610,24 @@ def get_variation_for_rollout(
610610
return Decision(experiment=rule, variation=forced_decision_variation,
611611
source=enums.DecisionSources.ROLLOUT, cmab_uuid=None), decide_reasons
612612

613+
# Check local holdouts targeting this specific delivery rule (FSSDK-12369)
614+
local_holdouts = project_config.get_holdouts_for_rule(rule.id)
615+
for holdout in local_holdouts:
616+
local_holdout_decision = self.get_variation_for_holdout(
617+
holdout, user_context, project_config
618+
)
619+
decide_reasons.extend(local_holdout_decision['reasons'])
620+
621+
local_decision = local_holdout_decision['decision']
622+
if local_decision.variation is not None:
623+
message = (
624+
f"The user '{user_id}' is bucketed into local holdout '{holdout.key}' "
625+
f"for delivery rule '{rule.key}'."
626+
)
627+
self.logger.info(message)
628+
decide_reasons.append(message)
629+
return local_decision, decide_reasons
630+
613631
bucketing_id, bucket_reasons = self._get_bucketing_id(user_id, attributes)
614632
decide_reasons += bucket_reasons
615633

@@ -733,9 +751,9 @@ def get_decision_for_flag(
733751
reasons = decide_reasons.copy() if decide_reasons else []
734752
user_id = user_context.user_id
735753

736-
# Check holdouts
737-
holdouts = project_config.get_holdouts_for_flag(feature_flag.key)
738-
for holdout in holdouts:
754+
# Check global holdouts (flag level — before any rules are evaluated)
755+
global_holdouts = project_config.get_global_holdouts()
756+
for holdout in global_holdouts:
739757
holdout_decision = self.get_variation_for_holdout(holdout, user_context, project_config)
740758
reasons.extend(holdout_decision['reasons'])
741759

@@ -756,7 +774,7 @@ def get_decision_for_flag(
756774
'reasons': reasons
757775
}
758776

759-
# If no holdout decision, check experiments then rollouts
777+
# If no global holdout decision, check experiments then rollouts
760778
if feature_flag.experimentIds:
761779
for experiment_id in feature_flag.experimentIds:
762780
experiment = project_config.get_experiment_from_id(experiment_id)
@@ -778,6 +796,28 @@ def get_decision_for_flag(
778796
'reasons': reasons
779797
}
780798

799+
# Check local holdouts targeting this specific experiment rule (FSSDK-12369)
800+
local_holdouts = project_config.get_holdouts_for_rule(experiment.id)
801+
for holdout in local_holdouts:
802+
local_holdout_decision = self.get_variation_for_holdout(
803+
holdout, user_context, project_config
804+
)
805+
reasons.extend(local_holdout_decision['reasons'])
806+
807+
local_decision = local_holdout_decision['decision']
808+
if local_decision.variation is not None:
809+
message = (
810+
f"The user '{user_id}' is bucketed into local holdout '{holdout.key}' "
811+
f"for experiment rule '{experiment.key}'."
812+
)
813+
self.logger.info(message)
814+
reasons.append(message)
815+
return {
816+
'decision': local_holdout_decision['decision'],
817+
'error': False,
818+
'reasons': reasons
819+
}
820+
781821
# Get variation for experiment
782822
variation_result = self.get_variation(
783823
project_config, experiment, user_context, user_profile_tracker, reasons, decide_options

optimizely/entities.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -223,6 +223,7 @@ def __init__(
223223
trafficAllocation: list[TrafficAllocation],
224224
audienceIds: list[str],
225225
audienceConditions: Optional[Sequence[str | list[str]]] = None,
226+
includedRules: Optional[list[str]] = None,
226227
**kwargs: Any
227228
):
228229
self.id = id
@@ -232,6 +233,8 @@ def __init__(
232233
self.trafficAllocation = trafficAllocation
233234
self.audienceIds = audienceIds
234235
self.audienceConditions = audienceConditions
236+
# None = global holdout (applies to all rules); list of rule IDs = local holdout
237+
self.included_rules: Optional[list[str]] = includedRules
235238

236239
def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
237240
"""Returns audienceConditions if present, otherwise audienceIds.
@@ -241,6 +244,18 @@ def get_audience_conditions_or_ids(self) -> Sequence[str | list[str]]:
241244
"""
242245
return self.audienceConditions if self.audienceConditions is not None else self.audienceIds
243246

247+
@property
248+
def is_global(self) -> bool:
249+
"""Check if this is a global holdout (applies to all rules across all flags).
250+
251+
A holdout is global when includedRules is None (absent from datafile).
252+
An empty list [] is a local holdout that targets no rules (different from global).
253+
254+
Returns:
255+
True if included_rules is None (global), False otherwise (local).
256+
"""
257+
return self.included_rules is None
258+
244259
@property
245260
def is_activated(self) -> bool:
246261
"""Check if the holdout is activated (running).

optimizely/helpers/types.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,3 +130,4 @@ class HoldoutDict(ExperimentDict):
130130
Extends ExperimentDict with holdout-specific properties.
131131
"""
132132
holdoutStatus: HoldoutStatus
133+
includedRules: Optional[list[str]] # None = global holdout; list of rule IDs = local holdout

optimizely/project_config.py

Lines changed: 33 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,10 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
9393
holdouts_data: list[types.HoldoutDict] = config.get('holdouts', [])
9494
self.holdouts: list[entities.Holdout] = []
9595
self.holdout_id_map: dict[str, entities.Holdout] = {}
96-
self.flag_holdouts_map: dict[str, list[entities.Holdout]] = {}
96+
# Global holdouts (includedRules is None) — evaluated at flag level before any rule
97+
self.global_holdouts: list[entities.Holdout] = []
98+
# Rule-level holdouts — map from rule ID to holdouts targeting that rule
99+
self.rule_holdouts_map: dict[str, list[entities.Holdout]] = {}
97100

98101
# Convert holdout dicts to Holdout entities
99102
for holdout_data in holdouts_data:
@@ -108,6 +111,16 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
108111
# Map by ID for quick lookup
109112
self.holdout_id_map[holdout.id] = holdout
110113

114+
# Classify holdout as global or local based on includedRules
115+
if holdout.is_global:
116+
self.global_holdouts.append(holdout)
117+
else:
118+
# Local holdout — register for each targeted rule ID
119+
for rule_id in (holdout.included_rules or []):
120+
if rule_id not in self.rule_holdouts_map:
121+
self.rule_holdouts_map[rule_id] = []
122+
self.rule_holdouts_map[rule_id].append(holdout)
123+
111124
# Utility maps for quick lookup
112125
self.group_id_map: dict[str, entities.Group] = self._generate_key_map(self.groups, 'id', entities.Group)
113126
self.experiment_id_map: dict[str, entities.Experiment] = self._generate_key_map(
@@ -240,11 +253,6 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
240253
everyone_else_variation.variables, 'id', entities.Variation.VariableUsage
241254
)
242255

243-
# Map all running holdouts to this flag
244-
applicable_holdouts = list(self.holdout_id_map.values())
245-
if applicable_holdouts:
246-
self.flag_holdouts_map[feature.key] = applicable_holdouts
247-
248256
rollout = None if len(feature.rolloutId) == 0 else self.rollout_id_map[feature.rolloutId]
249257
if rollout:
250258
for exp in rollout.experiments:
@@ -878,19 +886,30 @@ def get_flag_variation(
878886

879887
return None
880888

881-
def get_holdouts_for_flag(self, flag_key: str) -> list[entities.Holdout]:
882-
""" Helper method to get holdouts from an applied feature flag.
889+
def get_global_holdouts(self) -> list[entities.Holdout]:
890+
"""Return all global holdouts (includedRules is None).
883891
884-
Args:
885-
flag_key: Key of the feature flag.
892+
Global holdouts are evaluated at flag level before any rule is checked.
886893
887894
Returns:
888-
The holdouts that apply for a specific flag as Holdout entity objects.
895+
List of global Holdout entities that are currently running.
889896
"""
890-
if not self.holdouts:
891-
return []
897+
return self.global_holdouts
898+
899+
def get_holdouts_for_rule(self, rule_id: str) -> list[entities.Holdout]:
900+
"""Return local holdouts that target a specific rule.
892901
893-
return self.flag_holdouts_map.get(flag_key, [])
902+
Local holdouts are evaluated per-rule, before the rule's audience and
903+
traffic allocation checks. A rule ID not present in any holdout's
904+
includedRules simply returns an empty list — silently skipped.
905+
906+
Args:
907+
rule_id: The experiment or delivery rule ID to look up.
908+
909+
Returns:
910+
List of local Holdout entities targeting the given rule ID.
911+
"""
912+
return self.rule_holdouts_map.get(rule_id, [])
894913

895914
def get_holdout(self, holdout_id: str) -> Optional[entities.Holdout]:
896915
""" Helper method to get holdout from holdout ID.

0 commit comments

Comments
 (0)