From 747cbe6f8c202add36914d01998add3ffdf7c4c1 Mon Sep 17 00:00:00 2001 From: Jae Kim Date: Wed, 18 Feb 2026 14:54:52 -0800 Subject: [PATCH] [AI-FSSDK] [FSSDK-12262] Exclude CMAB from UserProfileService --- optimizely/decision_service.py | 14 ++- tests/test_decision_service.py | 178 +++++++++++++++++++++++++++++++++ 2 files changed, 190 insertions(+), 2 deletions(-) diff --git a/optimizely/decision_service.py b/optimizely/decision_service.py index 28275ef..5255b60 100644 --- a/optimizely/decision_service.py +++ b/optimizely/decision_service.py @@ -457,7 +457,11 @@ 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. + is_cmab_experiment = bool(experiment.cmab) + if user_profile_tracker is not None and not ignore_user_profile and not is_cmab_experiment: 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 ' \ @@ -473,6 +477,11 @@ def get_variation( else: self.logger.warning('User profile has invalid format.') + if is_cmab_experiment and user_profile_tracker is not None and not ignore_user_profile: + message = f'Skipping user profile service for CMAB experiment "{experiment.key}".' + self.logger.info(message) + decide_reasons.append(message) + # Check audience conditions audience_conditions = experiment.get_audience_conditions_or_ids() user_meets_audience_conditions, reasons_received = audience_helper.does_user_meet_audience_conditions( @@ -529,7 +538,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 is_cmab_experiment: 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..23ea0ae 100644 --- a/tests/test_decision_service.py +++ b/tests/test_decision_service.py @@ -1074,6 +1074,184 @@ def test_get_variation_cmab_experiment_with_whitelisted_variation(self): mock_bucket.assert_not_called() mock_cmab_decision.assert_not_called() + def test_get_variation_cmab_experiment_skips_ups_lookup(self): + """Test that get_variation skips UPS lookup for CMAB experiments.""" + + # Create a user context + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, + logger=None, + user_id="test_user", + user_attributes={} + ) + + # Create a CMAB experiment + cmab_experiment = entities.Experiment( + '111150', + 'cmab_experiment', + 'Running', + '111150', + [], + {}, + [entities.Variation('111151', 'variation_1')], + [{'entityId': '111151', 'endOfRange': 10000}], + cmab={'trafficAllocation': 5000} + ) + + # Set up a user profile tracker with a stored variation + user_profile_tracker = user_profile.UserProfileTracker( + 'test_user', self.decision_service.user_profile_service, self.decision_service.logger + ) + user_profile_tracker.user_profile = user_profile.UserProfile( + 'test_user', + {'111150': {'variation_id': '111151'}} + ) + + with 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_cmab_service.get_decision.return_value = ( + {'variation_id': '111151', 'cmab_uuid': 'test-cmab-uuid'}, + [] + ) + + variation_result = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + user_profile_tracker + ) + + # Verify UPS lookup was NOT called for CMAB experiment + mock_get_stored.assert_not_called() + + # Verify the decision reason for UPS exclusion is present + self.assertIn( + 'Skipping user profile service for CMAB experiment "cmab_experiment".', + variation_result['reasons'] + ) + + # Verify CMAB service was still called + mock_cmab_service.get_decision.assert_called_once() + + def test_get_variation_cmab_experiment_skips_ups_save(self): + """Test that get_variation skips saving to UPS for CMAB experiments.""" + + # Create a user context + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, + logger=None, + user_id="test_user", + user_attributes={} + ) + + # Create a CMAB experiment + cmab_experiment = entities.Experiment( + '111150', + 'cmab_experiment', + 'Running', + '111150', + [], + {}, + [entities.Variation('111151', 'variation_1')], + [{'entityId': '111151', 'endOfRange': 10000}], + cmab={'trafficAllocation': 5000} + ) + + # Set up a user profile tracker + user_profile_tracker = user_profile.UserProfileTracker( + 'test_user', self.decision_service.user_profile_service, self.decision_service.logger + ) + + with 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(user_profile_tracker, 'update_user_profile') as mock_update_profile: + + mock_cmab_service.get_decision.return_value = ( + {'variation_id': '111151', 'cmab_uuid': 'test-cmab-uuid'}, + [] + ) + + variation_result = self.decision_service.get_variation( + self.project_config, + cmab_experiment, + user, + user_profile_tracker + ) + + # Verify UPS save was NOT called for CMAB experiment + mock_update_profile.assert_not_called() + + # Verify we still got the correct variation + self.assertEqual(entities.Variation('111151', 'variation_1'), variation_result['variation']) + self.assertEqual('test-cmab-uuid', variation_result['cmab_uuid']) + self.assertStrictFalse(variation_result['error']) + + def test_get_variation_non_cmab_experiment_still_uses_ups(self): + """Test that non-CMAB experiments still use UPS for lookup and save.""" + + # Create a user context + user = optimizely_user_context.OptimizelyUserContext( + optimizely_client=None, + logger=None, + user_id="test_user", + user_attributes={} + ) + + # Create a non-CMAB experiment (no cmab attribute) + experiment = entities.Experiment( + '111150', + 'regular_experiment', + 'Running', + '111150', + [], + {}, + [entities.Variation('111151', 'variation_1')], + [{'entityId': '111151', 'endOfRange': 10000}] + ) + + stored_variation = entities.Variation('111151', 'variation_1') + + # Set up a user profile tracker with a stored variation + user_profile_tracker = user_profile.UserProfileTracker( + 'test_user', self.decision_service.user_profile_service, self.decision_service.logger + ) + user_profile_tracker.user_profile = user_profile.UserProfile( + 'test_user', + {'111150': {'variation_id': '111151'}} + ) + + with mock.patch.object(self.decision_service, 'get_stored_variation', + return_value=stored_variation) as mock_get_stored: + + variation_result = self.decision_service.get_variation( + self.project_config, + experiment, + user, + user_profile_tracker + ) + + # Verify UPS lookup WAS called for non-CMAB experiment + mock_get_stored.assert_called_once() + + # Verify the UPS exclusion message is NOT in reasons + for reason in variation_result['reasons']: + self.assertNotIn('Skipping user profile service', reason) + + # Verify we got the stored variation + self.assertEqual(stored_variation, variation_result['variation']) + class FeatureFlagDecisionTests(base.BaseTest): def setUp(self):