Skip to content

Commit b397b3b

Browse files
committed
[AI-FSSDK] [FSSDK-12369] Add local holdouts support (includedRules, rule-level evaluation)
1 parent 807d75b commit b397b3b

5 files changed

Lines changed: 443 additions & 8 deletions

File tree

optimizely/decision_service.py

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -599,8 +599,25 @@ def get_variation_for_rollout(
599599
while index < len(rollout_rules):
600600
skip_to_everyone_else = False
601601

602-
# check forced decision first
603602
rule = rollout_rules[index]
603+
604+
# Check local holdouts targeting this delivery rule (NEW - local holdouts)
605+
local_holdouts = project_config.get_holdouts_for_rule(rule.id)
606+
for local_holdout in local_holdouts:
607+
local_holdout_decision = self.get_variation_for_holdout(
608+
local_holdout, user_context, project_config
609+
)
610+
decide_reasons.extend(local_holdout_decision['reasons'])
611+
if local_holdout_decision['decision'].variation is not None:
612+
message = (
613+
f"The user '{user_id}' is bucketed into local holdout "
614+
f"'{local_holdout.key}' for rollout rule '{rule.key}'."
615+
)
616+
self.logger.info(message)
617+
decide_reasons.append(message)
618+
return local_holdout_decision['decision'], decide_reasons
619+
620+
# check forced decision first
604621
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(feature.key, rule.key)
605622
forced_decision_variation, reasons_received = self.validated_forced_decision(
606623
project_config, optimizely_decision_context, user_context)
@@ -733,8 +750,8 @@ def get_decision_for_flag(
733750
reasons = decide_reasons.copy() if decide_reasons else []
734751
user_id = user_context.user_id
735752

736-
# Check holdouts
737-
holdouts = project_config.get_holdouts_for_flag(feature_flag.key)
753+
# Check global holdouts (evaluated at flag level, before forced decisions)
754+
holdouts = project_config.get_global_holdouts()
738755
for holdout in holdouts:
739756
holdout_decision = self.get_variation_for_holdout(holdout, user_context, project_config)
740757
reasons.extend(holdout_decision['reasons'])
@@ -762,6 +779,26 @@ def get_decision_for_flag(
762779
experiment = project_config.get_experiment_from_id(experiment_id)
763780

764781
if experiment:
782+
# Check local holdouts targeting this experiment rule (NEW - local holdouts)
783+
local_holdouts = project_config.get_holdouts_for_rule(experiment.id)
784+
for local_holdout in local_holdouts:
785+
local_holdout_decision = self.get_variation_for_holdout(
786+
local_holdout, user_context, project_config
787+
)
788+
reasons.extend(local_holdout_decision['reasons'])
789+
if local_holdout_decision['decision'].variation is not None:
790+
message = (
791+
f"The user '{user_id}' is bucketed into local holdout "
792+
f"'{local_holdout.key}' for experiment rule '{experiment.key}'."
793+
)
794+
self.logger.info(message)
795+
reasons.append(message)
796+
return {
797+
'decision': local_holdout_decision['decision'],
798+
'error': False,
799+
'reasons': reasons
800+
}
801+
765802
# Check for forced decision
766803
optimizely_decision_context = OptimizelyUserContext.OptimizelyDecisionContext(
767804
feature_flag.key, experiment.key)

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.
@@ -253,6 +256,18 @@ def is_activated(self) -> bool:
253256
"""
254257
return self.status == self.Status.RUNNING
255258

259+
@property
260+
def is_global(self) -> bool:
261+
"""Check if this is a global holdout (applies to all rules).
262+
263+
A holdout is global when includedRules is None.
264+
An empty list [] means a local holdout with no matching rules (not global).
265+
266+
Returns:
267+
True if includedRules is None (global), False if includedRules is a list (local).
268+
"""
269+
return self.included_rules is None
270+
256271
def __str__(self) -> str:
257272
return self.key
258273

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: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -93,7 +93,12 @@ 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+
# Legacy flag-level map kept for backward compatibility
9697
self.flag_holdouts_map: dict[str, list[entities.Holdout]] = {}
98+
# Global holdouts (includedRules is None) — evaluated at flag level
99+
self.global_holdouts: list[entities.Holdout] = []
100+
# Rule-level holdouts map: rule_id -> [Holdout] for local holdouts
101+
self.rule_holdouts_map: dict[str, list[entities.Holdout]] = {}
97102

98103
# Convert holdout dicts to Holdout entities
99104
for holdout_data in holdouts_data:
@@ -108,6 +113,16 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
108113
# Map by ID for quick lookup
109114
self.holdout_id_map[holdout.id] = holdout
110115

116+
# Categorize holdout as global or local
117+
if holdout.is_global:
118+
self.global_holdouts.append(holdout)
119+
else:
120+
# Local holdout: map each included rule ID to this holdout
121+
for rule_id in holdout.included_rules or []:
122+
if rule_id not in self.rule_holdouts_map:
123+
self.rule_holdouts_map[rule_id] = []
124+
self.rule_holdouts_map[rule_id].append(holdout)
125+
111126
# Utility maps for quick lookup
112127
self.group_id_map: dict[str, entities.Group] = self._generate_key_map(self.groups, 'id', entities.Group)
113128
self.experiment_id_map: dict[str, entities.Experiment] = self._generate_key_map(
@@ -240,10 +255,9 @@ def __init__(self, datafile: str | bytes, logger: Logger, error_handler: Any):
240255
everyone_else_variation.variables, 'id', entities.Variation.VariableUsage
241256
)
242257

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
258+
# Map global holdouts to this flag (for legacy flag-level access)
259+
if self.global_holdouts:
260+
self.flag_holdouts_map[feature.key] = list(self.global_holdouts)
247261

248262
rollout = None if len(feature.rolloutId) == 0 else self.rollout_id_map[feature.rolloutId]
249263
if rollout:
@@ -881,17 +895,42 @@ def get_flag_variation(
881895
def get_holdouts_for_flag(self, flag_key: str) -> list[entities.Holdout]:
882896
""" Helper method to get holdouts from an applied feature flag.
883897
898+
Returns global holdouts for the given flag (backward-compatible).
899+
884900
Args:
885901
flag_key: Key of the feature flag.
886902
887903
Returns:
888-
The holdouts that apply for a specific flag as Holdout entity objects.
904+
The global holdouts that apply for a specific flag as Holdout entity objects.
889905
"""
890906
if not self.holdouts:
891907
return []
892908

893909
return self.flag_holdouts_map.get(flag_key, [])
894910

911+
def get_global_holdouts(self) -> list[entities.Holdout]:
912+
"""Return all global holdouts (holdouts with includedRules == None).
913+
914+
Global holdouts are evaluated at flag level before forced decisions.
915+
916+
Returns:
917+
List of global Holdout entities.
918+
"""
919+
return self.global_holdouts
920+
921+
def get_holdouts_for_rule(self, rule_id: str) -> list[entities.Holdout]:
922+
"""Return local holdouts targeting a specific rule.
923+
924+
Local holdouts are evaluated per-rule before audience and traffic checks.
925+
926+
Args:
927+
rule_id: The experiment or delivery rule ID to look up.
928+
929+
Returns:
930+
List of Holdout entities targeting the given rule ID.
931+
"""
932+
return self.rule_holdouts_map.get(rule_id, [])
933+
895934
def get_holdout(self, holdout_id: str) -> Optional[entities.Holdout]:
896935
""" Helper method to get holdout from holdout ID.
897936

0 commit comments

Comments
 (0)