Skip to content

Commit c0c1a6c

Browse files
committed
[AI-FSSDK] [FSSDK-12735] Add holdout event dispatch, local holdout flag isolation, and TD null passthrough
1 parent b91dc09 commit c0c1a6c

3 files changed

Lines changed: 123 additions & 42 deletions

File tree

optimizely/decision_service.py

Lines changed: 16 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -64,15 +64,11 @@ class VariationResult(TypedDict):
6464
variation: Optional[Union[entities.Variation, VariationDict]]
6565

6666

67-
class DecisionResult(TypedDict):
68-
"""
69-
A TypedDict representing the result of a decision process.
67+
class _DecisionResultOptional(TypedDict, total=False):
68+
holdout_decision: Decision
7069

71-
Attributes:
72-
decision (Decision): The decision object containing the outcome of the evaluation.
73-
error (bool): Indicates whether an error occurred during the decision process.
74-
reasons (List[str]): A list of reasons explaining the decision or any errors encountered.
75-
"""
70+
71+
class DecisionResult(_DecisionResultOptional):
7672
decision: Decision
7773
error: bool
7874
reasons: List[str]
@@ -612,9 +608,6 @@ def get_variation_for_rollout(
612608

613609
local_holdouts = project_config.get_holdouts_for_rule(rule.id)
614610
for holdout in local_holdouts:
615-
if holdout.exclude_targeted_deliveries:
616-
continue
617-
618611
local_holdout_decision = self.get_variation_for_holdout(
619612
holdout, user_context, project_config
620613
)
@@ -807,17 +800,17 @@ def get_decision_for_flag(
807800
if forced_decision_variation:
808801
decision = Decision(experiment, forced_decision_variation,
809802
enums.DecisionSources.FEATURE_TEST, None)
810-
return {
803+
result: DecisionResult = {
811804
'decision': decision,
812805
'error': False,
813806
'reasons': reasons
814807
}
808+
if global_holdout_result is not None:
809+
result['holdout_decision'] = global_holdout_result['decision']
810+
return result
815811

816812
local_holdouts = project_config.get_holdouts_for_rule(experiment.id)
817813
for holdout in local_holdouts:
818-
if holdout.exclude_targeted_deliveries and experiment.type == enums.ExperimentTypes.td:
819-
continue
820-
821814
local_holdout_decision = self.get_variation_for_holdout(
822815
holdout, user_context, project_config
823816
)
@@ -856,11 +849,14 @@ def get_decision_for_flag(
856849
decision = Decision(experiment, variation_result['variation'],
857850
enums.DecisionSources.FEATURE_TEST,
858851
variation_result['cmab_uuid'])
859-
return {
852+
result: DecisionResult = {
860853
'decision': decision,
861854
'error': False,
862855
'reasons': reasons
863856
}
857+
if global_holdout_result is not None:
858+
result['holdout_decision'] = global_holdout_result['decision']
859+
return result
864860

865861
# If no experiment decision, check rollouts
866862
rollout_decision, rollout_reasons = self.get_variation_for_rollout(
@@ -882,18 +878,14 @@ def get_decision_for_flag(
882878
else:
883879
self.logger.debug(f'User "{user_id}" not bucketed into any rollout for feature "{feature_flag.key}".')
884880

885-
if global_holdout_result is not None and not has_variation:
886-
return {
887-
'decision': global_holdout_result['decision'],
888-
'error': False,
889-
'reasons': reasons
890-
}
891-
892-
return {
881+
final_result: DecisionResult = {
893882
'decision': rollout_decision,
894883
'error': False,
895884
'reasons': reasons
896885
}
886+
if global_holdout_result is not None:
887+
final_result['holdout_decision'] = global_holdout_result['decision']
888+
return final_result
897889

898890
def get_variation_for_holdout(
899891
self,

optimizely/optimizely.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1245,7 +1245,8 @@ def _create_optimizely_decision(
12451245
flag_decision: Decision,
12461246
decision_reasons: Optional[list[str]],
12471247
decide_options: list[str],
1248-
project_config: ProjectConfig
1248+
project_config: ProjectConfig,
1249+
holdout_decision: Optional[Decision] = None
12491250
) -> OptimizelyDecision:
12501251
user_id = user_context.user_id
12511252
feature_enabled = False
@@ -1264,6 +1265,25 @@ def _create_optimizely_decision(
12641265

12651266
feature_flag = project_config.feature_key_map.get(flag_key)
12661267

1268+
# Send holdout impression when user was bucketed into a holdout bypassed due to exclude_targeted_deliveries
1269+
if (holdout_decision is not None
1270+
and decision_source != DecisionSources.HOLDOUT
1271+
and OptimizelyDecideOption.DISABLE_DECISION_EVENT not in decide_options):
1272+
holdout_enabled = self._get_feature_enabled(holdout_decision.variation)
1273+
holdout_rule_key = holdout_decision.experiment.key if holdout_decision.experiment else ''
1274+
self._send_impression_event(
1275+
project_config,
1276+
holdout_decision.experiment,
1277+
holdout_decision.variation,
1278+
flag_key,
1279+
holdout_rule_key,
1280+
str(DecisionSources.HOLDOUT),
1281+
holdout_enabled,
1282+
user_id,
1283+
attributes,
1284+
holdout_decision.cmab_uuid
1285+
)
1286+
12671287
# Send impression event if Decision came from a feature
12681288
if OptimizelyDecideOption.DISABLE_DECISION_EVENT not in decide_options:
12691289
if (decision_source == DecisionSources.FEATURE_TEST or
@@ -1448,11 +1468,13 @@ def _decide_for_keys(
14481468
user_context,
14491469
merged_decide_options
14501470
)
1471+
holdout_decisions: dict[str, Optional[Decision]] = {}
14511472
for i in range(0, len(flags_without_forced_decision)):
14521473
decision = decision_list[i]['decision']
14531474
reasons = decision_list[i]['reasons']
14541475
error = decision_list[i]['error']
14551476
flag_key = flags_without_forced_decision[i].key
1477+
holdout_decisions[flag_key] = decision_list[i].get('holdout_decision')
14561478
# store error decision against key and remove key from valid keys
14571479
if error:
14581480
optimizely_decision = OptimizelyDecision.new_error_decision(flags_without_forced_decision[i].key,
@@ -1472,7 +1494,8 @@ def _decide_for_keys(
14721494
flag_decision,
14731495
decision_reasons,
14741496
merged_decide_options,
1475-
project_config
1497+
project_config,
1498+
holdout_decision=holdout_decisions.get(key)
14761499
)
14771500
enabled_flags_only_missing = OptimizelyDecideOption.ENABLED_FLAGS_ONLY not in merged_decide_options
14781501
is_enabled = optimizely_decision.enabled

tests/test_decision_service_holdout.py

Lines changed: 82 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1839,7 +1839,9 @@ def test_global_holdout_exclude_td_true_allows_td_experiment(self):
18391839
# ------------------------------------------------------------------
18401840

18411841
def test_global_holdout_exclude_td_true_blocks_ab_experiment(self):
1842-
"""Global holdout with exclude_targeted_deliveries=True still blocks A/B rules."""
1842+
"""Global holdout with exclude_targeted_deliveries=True still blocks A/B rules.
1843+
The A/B experiment is skipped and a non-holdout decision is returned,
1844+
with the holdout decision attached separately."""
18431845
opt = self._make_opt_with_td(
18441846
[_holdout_with_etd('gh1', 'global_exclude_td', exclude_targeted_deliveries=True)],
18451847
experiment_type='ab',
@@ -1854,18 +1856,17 @@ def test_global_holdout_exclude_td_true_blocks_ab_experiment(self):
18541856
result = ds.get_decision_for_flag(feature_flag, user_ctx, config)
18551857

18561858
decision = result['decision']
1857-
# A/B experiment should be skipped; user stays in holdout
1858-
self.assertEqual(decision.source, enums.DecisionSources.HOLDOUT)
1859+
self.assertNotEqual(decision.source, enums.DecisionSources.HOLDOUT)
18591860
mock_get_var.assert_not_called()
1861+
self.assertIsNotNone(result.get('holdout_decision'))
18601862

18611863
# ------------------------------------------------------------------
18621864
# Test 4: Global holdout with exclude_targeted_deliveries=True, no TD matches
18631865
# ------------------------------------------------------------------
18641866

1865-
def test_global_holdout_exclude_td_true_no_td_falls_back_to_holdout(self):
1867+
def test_global_holdout_exclude_td_true_no_td_returns_non_holdout_decision(self):
18661868
"""When exclude_targeted_deliveries=True but no TD experiments match,
1867-
falls back to holdout decision."""
1868-
# No type set on experiment (defaults to None, not 'td')
1869+
returns a non-holdout decision with the bypassed holdout attached."""
18691870
opt = self._make_opt_with_td(
18701871
[_holdout_with_etd('gh1', 'global_exclude_td', exclude_targeted_deliveries=True)],
18711872
)
@@ -1877,16 +1878,16 @@ def test_global_holdout_exclude_td_true_no_td_falls_back_to_holdout(self):
18771878
result = ds.get_decision_for_flag(feature_flag, user_ctx, config)
18781879

18791880
decision = result['decision']
1880-
# No TD experiment available, so holdout decision should be returned
1881-
self.assertEqual(decision.source, enums.DecisionSources.HOLDOUT)
1881+
self.assertNotEqual(decision.source, enums.DecisionSources.HOLDOUT)
1882+
self.assertIsNotNone(result.get('holdout_decision'))
18821883

18831884
# ------------------------------------------------------------------
18841885
# Test 5: Local holdout on delivery rule with exclude_targeted_deliveries=True
18851886
# ------------------------------------------------------------------
18861887

1887-
def test_local_holdout_delivery_rule_exclude_td_true_skips_holdout(self):
1888+
def test_local_holdout_delivery_rule_exclude_td_true_still_applies(self):
18881889
"""Local holdout on delivery rule with exclude_targeted_deliveries=True
1889-
skips the holdout and evaluates the delivery rule."""
1890+
still applies because local holdouts ignore that flag."""
18901891
delivery_rule_id = '211147'
18911892
opt = self._make_opt_with_td(
18921893
[_holdout_with_etd(
@@ -1903,16 +1904,15 @@ def test_local_holdout_delivery_rule_exclude_td_true_skips_holdout(self):
19031904
result = ds.get_decision_for_flag(feature_flag, user_ctx, config)
19041905

19051906
decision = result['decision']
1906-
# Delivery rules are targeted deliveries, so holdout should be skipped
1907-
self.assertNotEqual(decision.source, enums.DecisionSources.HOLDOUT)
1907+
self.assertEqual(decision.source, enums.DecisionSources.HOLDOUT)
19081908

19091909
# ------------------------------------------------------------------
19101910
# Test 6: Local holdout on experiment rule (TD type) with exclude_targeted_deliveries=True
19111911
# ------------------------------------------------------------------
19121912

1913-
def test_local_holdout_td_experiment_exclude_td_true_skips_holdout(self):
1913+
def test_local_holdout_td_experiment_exclude_td_true_still_applies(self):
19141914
"""Local holdout on TD experiment with exclude_targeted_deliveries=True
1915-
skips the holdout."""
1915+
still applies because local holdouts ignore that flag."""
19161916
experiment_rule_id = '111127'
19171917
opt = self._make_opt_with_td(
19181918
[_holdout_with_etd(
@@ -1930,8 +1930,7 @@ def test_local_holdout_td_experiment_exclude_td_true_skips_holdout(self):
19301930
result = ds.get_decision_for_flag(feature_flag, user_ctx, config)
19311931

19321932
decision = result['decision']
1933-
# TD experiment should bypass the local holdout
1934-
self.assertNotEqual(decision.source, enums.DecisionSources.HOLDOUT)
1933+
self.assertEqual(decision.source, enums.DecisionSources.HOLDOUT)
19351934

19361935
# ------------------------------------------------------------------
19371936
# Test 7: Local holdout on experiment rule (A/B type) with exclude_targeted_deliveries=True
@@ -1990,3 +1989,70 @@ def test_missing_exclude_td_field_defaults_to_false(self):
19901989
holdout = config.holdouts[0] if config.holdouts else None
19911990
self.assertIsNotNone(holdout)
19921991
self.assertFalse(holdout.exclude_targeted_deliveries)
1992+
1993+
# ------------------------------------------------------------------
1994+
# Test 9: TD match returns holdout_decision in result
1995+
# ------------------------------------------------------------------
1996+
1997+
def test_global_holdout_exclude_td_true_td_match_has_holdout_decision(self):
1998+
"""When exclude_targeted_deliveries=True and a TD experiment matches,
1999+
the result contains holdout_decision with the bypassed holdout."""
2000+
opt = self._make_opt_with_td(
2001+
[_holdout_with_etd('gh1', 'global_exclude_td', exclude_targeted_deliveries=True)],
2002+
experiment_type='td',
2003+
)
2004+
config = opt.config_manager.get_config()
2005+
feature_flag = config.get_feature_from_key('test_feature_in_experiment')
2006+
2007+
ds = self._decision_svc()
2008+
user_ctx = opt.create_user_context('testUserId', {})
2009+
result = ds.get_decision_for_flag(feature_flag, user_ctx, config)
2010+
2011+
decision = result['decision']
2012+
self.assertNotEqual(decision.source, enums.DecisionSources.HOLDOUT)
2013+
self.assertIsNotNone(result.get('holdout_decision'))
2014+
self.assertEqual(result['holdout_decision'].source, enums.DecisionSources.HOLDOUT)
2015+
2016+
# ------------------------------------------------------------------
2017+
# Test 10: No TD match returns non-holdout with holdout_decision attached
2018+
# ------------------------------------------------------------------
2019+
2020+
def test_global_holdout_exclude_td_true_no_td_has_holdout_decision(self):
2021+
"""When exclude_targeted_deliveries=True and no TD matches,
2022+
result is non-holdout but holdout_decision key is present."""
2023+
opt = self._make_opt_with_td(
2024+
[_holdout_with_etd('gh1', 'global_exclude_td', exclude_targeted_deliveries=True)],
2025+
)
2026+
config = opt.config_manager.get_config()
2027+
feature_flag = config.get_feature_from_key('test_feature_in_experiment')
2028+
2029+
ds = self._decision_svc()
2030+
user_ctx = opt.create_user_context('user_no_td', {})
2031+
result = ds.get_decision_for_flag(feature_flag, user_ctx, config)
2032+
2033+
decision = result['decision']
2034+
self.assertNotEqual(decision.source, enums.DecisionSources.HOLDOUT)
2035+
holdout_dec = result.get('holdout_decision')
2036+
self.assertIsNotNone(holdout_dec)
2037+
self.assertEqual(holdout_dec.source, enums.DecisionSources.HOLDOUT)
2038+
2039+
# ------------------------------------------------------------------
2040+
# Test 11: Holdout impression event dispatched for bypassed holdout
2041+
# ------------------------------------------------------------------
2042+
2043+
def test_holdout_impression_sent_when_td_evaluated(self):
2044+
"""When exclude_targeted_deliveries=True and TD matches,
2045+
two impression events are sent: one for holdout, one for TD."""
2046+
opt = self._make_opt_with_td(
2047+
[_holdout_with_etd('gh1', 'global_exclude_td', exclude_targeted_deliveries=True)],
2048+
experiment_type='td',
2049+
)
2050+
with mock.patch.object(opt, '_send_impression_event') as mock_send:
2051+
user_ctx = opt.create_user_context('testUserId', {})
2052+
user_ctx.decide('test_feature_in_experiment')
2053+
2054+
self.assertEqual(mock_send.call_count, 2)
2055+
call_rule_types = [call.args[5] if len(call.args) > 5 else call.kwargs.get('rule_type')
2056+
for call in mock_send.call_args_list]
2057+
self.assertIn(str(enums.DecisionSources.HOLDOUT), call_rule_types)
2058+
self.assertIn(str(enums.DecisionSources.FEATURE_TEST), call_rule_types)

0 commit comments

Comments
 (0)