From 607034a8e21022dfebb4af56f64c41465e4797cb Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Mon, 9 Feb 2026 16:49:14 -0800 Subject: [PATCH] [AI-FSSDK] [FSSDK-12262] Exclude CMAB from UserProfileService --- optimizely/decision_service.py | 12 +- tests/test_decision_service.py | 206 +++++++++++++++++++++++++++++++++ 2 files changed, 216 insertions(+), 2 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 28275ef..411aa44 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -457,7 +457,14 @@ def get_variation( } # Check to see if user has a decision available for the given experiment - if user_profile_tracker is not None and not ignore_user_profile: + # CMAB experiments are excluded from UPS because UPS maintains decisions + # across the experiment lifetime without considering TTL or user attributes, + # which contradicts CMAB's dynamic nature. + if experiment.cmab: + message = f'Skipping user profile service for CMAB experiment "{experiment.key}".' + self.logger.info(message) + decide_reasons.append(message) + elif user_profile_tracker is not None and not ignore_user_profile: variation = self.get_stored_variation(project_config, experiment, user_profile_tracker.get_user_profile()) if variation: message = f'Returning previously activated variation ID "{variation}" of experiment ' \ @@ -529,7 +536,8 @@ def get_variation( self.logger.info(message) decide_reasons.append(message) # Store this new decision and return the variation for the user - if user_profile_tracker is not None and not ignore_user_profile: + # CMAB experiments are excluded from UPS to preserve dynamic decision-making + if user_profile_tracker is not None and not ignore_user_profile and not experiment.cmab: try: user_profile_tracker.update_user_profile(experiment, variation) except: diff --git a/tests/test_decision_service.py b/tests/test_decision_service.py index dbcb743..a977359 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -1890,3 +1890,209 @@ def test_get_variation_for_feature_returns_rollout_in_experiment_bucket_range_25 mock_config_logging.debug.assert_called_with( 'Assigned bucket 4000 to user with bucketing ID "test_user".') mock_generate_bucket_value.assert_called_with("test_user211147") + + def test_get_variation_cmab_experiment_skips_ups_lookup(self): + """Test that CMAB experiments skip user profile service lookup.""" + + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, + logger=None, + user_id="test_user", + user_attributes={} + ) + + cmab_experiment = entities.Experiment( + '111150', + 'cmab_experiment', + 'Running', + '111150', + [], + {}, + [ + entities.Variation('111151', 'variation_1'), + entities.Variation('111152', 'variation_2') + ], + [ + {'entityId': '111151', 'endOfRange': 5000}, + {'entityId': '111152', 'endOfRange': 10000} + ], + cmab={'trafficAllocation': 5000} + ) + + # Create a user profile tracker with a stored variation for this experiment + ups = user_profile.UserProfileService() + tracker = user_profile.UserProfileTracker("test_user", ups, self.decision_service.logger) + tracker.user_profile = user_profile.UserProfile( + "test_user", + {"111150": {"variation_id": "111151"}} + ) + + with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \ + mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=[True, []]), \ + mock.patch.object(self.decision_service.bucketer, 'bucket_to_entity_id', + return_value=['$', []]), \ + mock.patch.object(self.decision_service, 'cmab_service') as mock_cmab_service, \ + mock.patch.object(self.project_config, 'get_variation_from_id', + return_value=entities.Variation('111151', 'variation_1')), \ + mock.patch.object(self.decision_service, 'get_stored_variation') as mock_get_stored, \ + mock.patch.object(self.decision_service, + 'logger') as mock_logger: + + mock_cmab_service.get_decision.return_value = ( + { + 'variation_id': '111151', + 'cmab_uuid': 'test-cmab-uuid-123' + }, + [] + ) + + variation_result = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + tracker + ) + + # Verify get_stored_variation was NOT called for CMAB experiment + mock_get_stored.assert_not_called() + + # Verify the UPS skip reason is in the decision reasons + reasons = variation_result['reasons'] + self.assertIn( + 'Skipping user profile service for CMAB experiment "cmab_experiment".', + reasons + ) + + # Verify the variation was still resolved via CMAB service + self.assertEqual(entities.Variation('111151', 'variation_1'), variation_result['variation']) + self.assertEqual('test-cmab-uuid-123', variation_result['cmab_uuid']) + self.assertStrictFalse(variation_result['error']) + + mock_logger.info.assert_any_call( + 'Skipping user profile service for CMAB experiment "cmab_experiment".' + ) + + def test_get_variation_cmab_experiment_skips_ups_save(self): + """Test that CMAB experiments do not save decisions to user profile service.""" + + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, + logger=None, + user_id="test_user", + user_attributes={} + ) + + cmab_experiment = entities.Experiment( + '111150', + 'cmab_experiment', + 'Running', + '111150', + [], + {}, + [ + entities.Variation('111151', 'variation_1'), + entities.Variation('111152', 'variation_2') + ], + [ + {'entityId': '111151', 'endOfRange': 5000}, + {'entityId': '111152', 'endOfRange': 10000} + ], + cmab={'trafficAllocation': 5000} + ) + + ups = user_profile.UserProfileService() + tracker = user_profile.UserProfileTracker("test_user", ups, self.decision_service.logger) + + with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \ + mock.patch('optimizely.helpers.audience.does_user_meet_audience_conditions', return_value=[True, []]), \ + mock.patch.object(self.decision_service.bucketer, 'bucket_to_entity_id', + return_value=['$', []]), \ + mock.patch.object(self.decision_service, 'cmab_service') as mock_cmab_service, \ + mock.patch.object(self.project_config, 'get_variation_from_id', + return_value=entities.Variation('111151', 'variation_1')), \ + mock.patch.object(tracker, 'update_user_profile') as mock_update_profile, \ + mock.patch.object(self.decision_service, 'logger'): + + mock_cmab_service.get_decision.return_value = ( + { + 'variation_id': '111151', + 'cmab_uuid': 'test-cmab-uuid-123' + }, + [] + ) + + variation_result = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + tracker + ) + + # Verify update_user_profile was NOT called for CMAB experiment + mock_update_profile.assert_not_called() + + # Verify the variation was still returned correctly + self.assertEqual(entities.Variation('111151', 'variation_1'), variation_result['variation']) + self.assertEqual('test-cmab-uuid-123', variation_result['cmab_uuid']) + self.assertStrictFalse(variation_result['error']) + + def test_get_variation_non_cmab_experiment_uses_ups(self): + """Test that non-CMAB experiments still use user profile service normally.""" + + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, + logger=None, + user_id="test_user", + user_attributes={} + ) + + # Create a non-CMAB experiment (cmab=None) + non_cmab_experiment = entities.Experiment( + '111127', + 'test_experiment', + 'Running', + '111182', + [], + {}, + [ + entities.Variation('111128', 'control'), + entities.Variation('111129', 'variation') + ], + [ + {'entityId': '111128', 'endOfRange': 5000}, + {'entityId': '111129', 'endOfRange': 10000} + ], + cmab=None + ) + + ups = user_profile.UserProfileService() + tracker = user_profile.UserProfileTracker("test_user", ups, self.decision_service.logger) + tracker.user_profile = user_profile.UserProfile( + "test_user", + {"111127": {"variation_id": "111128"}} + ) + + with mock.patch('optimizely.helpers.experiment.is_experiment_running', return_value=True), \ + mock.patch.object(self.decision_service, 'get_stored_variation', + return_value=entities.Variation('111128', 'control')) as mock_get_stored, \ + mock.patch.object(self.decision_service, 'logger') as mock_logger: + + variation_result = self.decision_service.get_variation( + self.project_config, + non_cmab_experiment, + user, + tracker + ) + + # Verify get_stored_variation WAS called for non-CMAB experiment + mock_get_stored.assert_called_once() + + # Verify the stored variation was returned + self.assertEqual(entities.Variation('111128', 'control'), variation_result['variation']) + self.assertIsNone(variation_result['cmab_uuid']) + self.assertStrictFalse(variation_result['error']) + + # Verify no UPS skip message in reasons + reasons = variation_result['reasons'] + for reason in reasons: + self.assertNotIn('Skipping user profile service', reason)