Skip to content

Commit c22bfd9

Browse files
committed
[FSSDK-11571] Python: Add holdout support and refactor decision logic in DefaultDecisionService
1 parent caba597 commit c22bfd9

File tree

6 files changed

+1179
-46
lines changed

6 files changed

+1179
-46
lines changed

optimizely/bucketer.py

Lines changed: 56 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -119,16 +119,41 @@ def bucket(
119119
and array of log messages representing decision making.
120120
*/.
121121
"""
122+
# Check if experiment is None first
123+
if not experiment:
124+
message = 'Invalid entity key provided for bucketing. Returning nil.'
125+
project_config.logger.debug(message)
126+
return None, []
127+
128+
if isinstance(experiment, dict):
129+
# This is a holdout dictionary
130+
experiment_key = experiment.get('key', '')
131+
experiment_id = experiment.get('id', '')
132+
else:
133+
# This is an Experiment object
134+
experiment_key = experiment.key
135+
experiment_id = experiment.id
136+
137+
if not experiment_key or not experiment_key.strip():
138+
message = 'Invalid entity key provided for bucketing. Returning nil.'
139+
project_config.logger.debug(message)
140+
return None, []
141+
122142
variation_id, decide_reasons = self.bucket_to_entity_id(project_config, experiment, user_id, bucketing_id)
123143
if variation_id:
124-
variation = project_config.get_variation_from_id_by_experiment_id(experiment.id, variation_id)
144+
if isinstance(experiment, dict):
145+
# For holdouts, find the variation in the holdout's variations array
146+
variations = experiment.get('variations', [])
147+
variation = next((v for v in variations if v.get('id') == variation_id), None)
148+
else:
149+
# For experiments, use the existing method
150+
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
125151
return variation, decide_reasons
126-
127-
else:
128-
message = 'Bucketed into an empty traffic range. Returning nil.'
129-
project_config.logger.info(message)
130-
decide_reasons.append(message)
131-
152+
153+
# No variation found - log message for empty traffic range
154+
message = 'Bucketed into an empty traffic range. Returning nil.'
155+
project_config.logger.info(message)
156+
decide_reasons.append(message)
132157
return None, decide_reasons
133158

134159
def bucket_to_entity_id(
@@ -151,9 +176,25 @@ def bucket_to_entity_id(
151176
if not experiment:
152177
return None, decide_reasons
153178

179+
# Handle both Experiment objects and holdout dictionaries
180+
if isinstance(experiment, dict):
181+
# This is a holdout dictionary - holdouts don't have groups
182+
experiment_key = experiment.get('key', '')
183+
experiment_id = experiment.get('id', '')
184+
traffic_allocations = experiment.get('trafficAllocation', [])
185+
has_cmab = False
186+
group_policy = None
187+
else:
188+
# This is an Experiment object
189+
experiment_key = experiment.key
190+
experiment_id = experiment.id
191+
traffic_allocations = experiment.trafficAllocation
192+
has_cmab = bool(experiment.cmab)
193+
group_policy = getattr(experiment, 'groupPolicy', None)
194+
154195
# Determine if experiment is in a mutually exclusive group.
155-
# This will not affect evaluation of rollout rules.
156-
if experiment.groupPolicy in GROUP_POLICIES:
196+
# This will not affect evaluation of rollout rules or holdouts.
197+
if group_policy and group_policy in GROUP_POLICIES:
157198
group = project_config.get_group(experiment.groupId)
158199

159200
if not group:
@@ -169,26 +210,26 @@ def bucket_to_entity_id(
169210
decide_reasons.append(message)
170211
return None, decide_reasons
171212

172-
if user_experiment_id != experiment.id:
173-
message = f'User "{user_id}" is not in experiment "{experiment.key}" of group {experiment.groupId}.'
213+
if user_experiment_id != experiment_id:
214+
message = f'User "{user_id}" is not in experiment "{experiment_key}" of group {experiment.groupId}.'
174215
project_config.logger.info(message)
175216
decide_reasons.append(message)
176217
return None, decide_reasons
177218

178-
message = f'User "{user_id}" is in experiment {experiment.key} of group {experiment.groupId}.'
219+
message = f'User "{user_id}" is in experiment {experiment_key} of group {experiment.groupId}.'
179220
project_config.logger.info(message)
180221
decide_reasons.append(message)
181222

182-
traffic_allocations: list[TrafficAllocation] = experiment.trafficAllocation
183-
if experiment.cmab:
223+
if has_cmab:
184224
traffic_allocations = [
185225
{
186226
"entityId": "$",
187227
"endOfRange": experiment.cmab['trafficAllocation']
188228
}
189229
]
230+
190231
# Bucket user if not in white-list and in group (if any)
191232
variation_id = self.find_bucket(project_config, bucketing_id,
192-
experiment.id, traffic_allocations)
233+
experiment_id, traffic_allocations)
193234

194235
return variation_id, decide_reasons

optimizely/decision_service.py

Lines changed: 154 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -670,7 +670,160 @@ def get_variation_for_feature(
670670
- 'error': Boolean indicating if an error occurred during the decision process.
671671
- 'reasons': List of log messages representing decision making for the feature.
672672
"""
673-
return self.get_variations_for_feature_list(project_config, [feature], user_context, options)[0]
673+
holdouts = project_config.get_holdouts_for_flag(feature.key)
674+
675+
if holdouts:
676+
# Has holdouts - use get_decision_for_flag which checks holdouts first
677+
return self.get_decision_for_flag(feature, user_context, project_config, options)
678+
else:
679+
return self.get_variations_for_feature_list(project_config, [feature], user_context, options)[0]
680+
681+
def get_decision_for_flag(
682+
self,
683+
feature_flag: entities.FeatureFlag,
684+
user_context: OptimizelyUserContext,
685+
project_config: ProjectConfig,
686+
decide_options: Optional[Sequence[str]] = None,
687+
user_profile_tracker: Optional[UserProfileTracker] = None,
688+
decide_reasons: Optional[list[str]] = None
689+
) -> DecisionResult:
690+
"""
691+
Get the decision for a single feature flag.
692+
Processes holdouts, experiments, and rollouts in that order.
693+
694+
Args:
695+
feature_flag: The feature flag to get a decision for.
696+
user_context: The user context.
697+
project_config: The project config.
698+
decide_options: Sequence of decide options.
699+
user_profile_tracker: The user profile tracker.
700+
decide_reasons: List of decision reasons to merge.
701+
702+
Returns:
703+
A DecisionResult for the feature flag.
704+
"""
705+
reasons = decide_reasons.copy() if decide_reasons else []
706+
user_id = user_context.user_id
707+
708+
# Check holdouts
709+
holdouts = project_config.get_holdouts_for_flag(feature_flag.key)
710+
for holdout in holdouts:
711+
holdout_decision = self.get_variation_for_holdout(holdout, user_context, project_config)
712+
reasons.extend(holdout_decision['reasons'])
713+
714+
if not holdout_decision['decision']:
715+
continue
716+
717+
message = f"The user '{user_id}' is bucketed into holdout '{holdout['key']}' " \
718+
f"for feature flag '{feature_flag.key}'."
719+
self.logger.info(message)
720+
reasons.append(message)
721+
return {
722+
'decision': holdout_decision['decision'],
723+
'error': False,
724+
'reasons': reasons
725+
}
726+
727+
# If no holdout decision, fall back to existing experiment/rollout logic
728+
# Use get_variations_for_feature_list which handles experiments and rollouts
729+
fallback_result = self.get_variations_for_feature_list(
730+
project_config, [feature_flag], user_context, decide_options
731+
)[0]
732+
733+
# Merge reasons
734+
if fallback_result.get('reasons'):
735+
reasons.extend(fallback_result['reasons'])
736+
737+
return {
738+
'decision': fallback_result.get('decision'),
739+
'error': fallback_result.get('error', False),
740+
'reasons': reasons
741+
}
742+
743+
def get_variation_for_holdout(
744+
self,
745+
holdout: dict[str, str],
746+
user_context: OptimizelyUserContext,
747+
project_config: ProjectConfig
748+
) -> DecisionResult:
749+
"""
750+
Get the variation for holdout.
751+
752+
Args:
753+
holdout: The holdout configuration.
754+
user_context: The user context.
755+
project_config: The project config.
756+
757+
Returns:
758+
A DecisionResult for the holdout.
759+
"""
760+
from optimizely.helpers.enums import ExperimentAudienceEvaluationLogs
761+
762+
decide_reasons: list[str] = []
763+
user_id = user_context.user_id
764+
attributes = user_context.get_user_attributes()
765+
766+
if not holdout or not holdout.get('status') or holdout.get('status') != 'Running':
767+
key = holdout.get('key') if holdout else 'unknown'
768+
message = f"Holdout '{key}' is not running."
769+
self.logger.info(message)
770+
decide_reasons.append(message)
771+
return {
772+
'decision': None,
773+
'error': False,
774+
'reasons': decide_reasons
775+
}
776+
777+
bucketing_id, bucketing_id_reasons = self._get_bucketing_id(user_id, attributes)
778+
decide_reasons.extend(bucketing_id_reasons)
779+
780+
# Check audience conditions
781+
audience_conditions = holdout.get('audienceIds')
782+
user_meets_audience_conditions, reasons_received = audience_helper.does_user_meet_audience_conditions(
783+
project_config,
784+
audience_conditions,
785+
ExperimentAudienceEvaluationLogs,
786+
holdout.get('key', 'unknown'),
787+
user_context,
788+
self.logger
789+
)
790+
decide_reasons.extend(reasons_received)
791+
792+
if not user_meets_audience_conditions:
793+
message = f"User '{user_id}' does not meet the conditions for holdout '{holdout['key']}'."
794+
self.logger.debug(message)
795+
decide_reasons.append(message)
796+
return {
797+
'decision': None,
798+
'error': False,
799+
'reasons': decide_reasons
800+
}
801+
802+
# Bucket user into holdout variation
803+
variation, bucket_reasons = self.bucketer.bucket(project_config, holdout, user_id, bucketing_id)
804+
decide_reasons.extend(bucket_reasons)
805+
806+
if variation:
807+
message = f"The user '{user_id}' is bucketed into variation '{variation['key']}' " \
808+
f"of holdout '{holdout['key']}'."
809+
self.logger.info(message)
810+
decide_reasons.append(message)
811+
812+
holdout_decision = Decision(holdout, variation, enums.DecisionSources.HOLDOUT, None)
813+
return {
814+
'decision': holdout_decision,
815+
'error': False,
816+
'reasons': decide_reasons
817+
}
818+
819+
message = f"User '{user_id}' is not bucketed into any variation for holdout '{holdout['key']}'."
820+
self.logger.info(message)
821+
decide_reasons.append(message)
822+
return {
823+
'decision': None,
824+
'error': False,
825+
'reasons': decide_reasons
826+
}
674827

675828
def validated_forced_decision(
676829
self,

optimizely/helpers/enums.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,7 @@ class DecisionSources:
9999
EXPERIMENT: Final = 'experiment'
100100
FEATURE_TEST: Final = 'feature-test'
101101
ROLLOUT: Final = 'rollout'
102+
HOLDOUT: Final = 'holdout'
102103

103104

104105
class Errors:

optimizely/project_config.py

Lines changed: 41 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,21 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
218218
# Add this experiment in experiment-feature map.
219219
self.experiment_feature_map[exp_id] = [feature.id]
220220
rules.append(self.experiment_id_map[exp_id])
221+
222+
flag_id = feature.id
223+
applicable_holdouts = []
224+
225+
if flag_id in self.included_holdouts:
226+
applicable_holdouts.extend(self.included_holdouts[flag_id])
227+
228+
for holdout in self.global_holdouts.values():
229+
excluded_flag_ids = holdout.get('excludedFlags', [])
230+
if flag_id not in excluded_flag_ids:
231+
applicable_holdouts.append(holdout)
232+
233+
if applicable_holdouts:
234+
self.flag_holdouts_map[feature.key] = applicable_holdouts
235+
221236
rollout = None if len(feature.rolloutId) == 0 else self.rollout_id_map[feature.rolloutId]
222237
if rollout:
223238
for exp in rollout.experiments:
@@ -231,6 +246,30 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
231246
variations.append(rule_variation)
232247
self.flag_variations_map[feature.key] = variations
233248

249+
if self.holdouts:
250+
for holdout in self.holdouts:
251+
holdout_key = holdout.get('key')
252+
holdout_id = holdout.get('id')
253+
254+
if not holdout_key or not holdout_id:
255+
continue
256+
257+
self.variation_key_map[holdout_key] = {}
258+
self.variation_id_map[holdout_key] = {}
259+
self.variation_id_map_by_experiment_id[holdout_id] = {}
260+
self.variation_key_map_by_experiment_id[holdout_id] = {}
261+
262+
variations = holdout.get('variations')
263+
if variations:
264+
for variation in variations:
265+
variation_key = variation.get('key')
266+
variation_id = variation.get('id')
267+
if variation_key and variation_id:
268+
self.variation_key_map[holdout_key][variation_key] = variation
269+
self.variation_id_map[holdout_key][variation_id] = variation
270+
self.variation_key_map_by_experiment_id[holdout_id][variation_key] = variation
271+
self.variation_id_map_by_experiment_id[holdout_id][variation_id] = variation
272+
234273
@staticmethod
235274
def _generate_key_map(
236275
entity_list: Iterable[Any], key: str, entity_class: Type[EntityClass], first_value: bool = False
@@ -794,38 +833,10 @@ def get_holdouts_for_flag(self, flag_key: str) -> list[Any]:
794833
Returns:
795834
The holdouts that apply for a specific flag.
796835
"""
797-
feature_flag = self.feature_key_map.get(flag_key)
798-
if not feature_flag:
836+
if not self.holdouts:
799837
return []
800838

801-
flag_id = feature_flag.id
802-
803-
# Check cache first
804-
if flag_id in self.flag_holdouts_map:
805-
return self.flag_holdouts_map[flag_id]
806-
807-
holdouts = []
808-
809-
# Add global holdouts that don't exclude this flag
810-
for holdout in self.global_holdouts.values():
811-
is_excluded = False
812-
excluded_flags = holdout.get('excludedFlags')
813-
if excluded_flags:
814-
for excluded_flag_id in excluded_flags:
815-
if excluded_flag_id == flag_id:
816-
is_excluded = True
817-
break
818-
if not is_excluded:
819-
holdouts.append(holdout)
820-
821-
# Add holdouts that specifically include this flag
822-
if flag_id in self.included_holdouts:
823-
holdouts.extend(self.included_holdouts[flag_id])
824-
825-
# Cache the result
826-
self.flag_holdouts_map[flag_id] = holdouts
827-
828-
return holdouts
839+
return self.flag_holdouts_map.get(flag_key, [])
829840

830841
def get_holdout(self, holdout_id: str) -> Optional[dict[str, Any]]:
831842
""" Helper method to get holdout from holdout ID.

0 commit comments

Comments
 (0)