From 7e5cc397495fe8c98b84865934d675bd86681077 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 5 Mar 2026 07:34:23 +0600 Subject: [PATCH 01/11] [AI-FSSDK] [FSSDK-12337] Add Feature Rollout support Add Feature Rollout support to the Ruby SDK. During project config parsing, inject the "everyone else" variation from the flag's rollout into any experiment with type "feature_rollout", enabling correct evaluation without changes to decision logic. - Added config parsing logic to inject the everyone else rollout variation into feature_rollout experiments - Added traffic allocation entry (endOfRange=10000) for injected variation - Added get_everyone_else_variation helper to extract the last rollout rule's first variation - Added 6 unit tests covering injection, variation maps, edge cases, and backward compatibility --- .../config/datafile_project_config.rb | 54 +++ spec/config/datafile_project_config_spec.rb | 347 ++++++++++++++++++ 2 files changed, 401 insertions(+) diff --git a/lib/optimizely/config/datafile_project_config.rb b/lib/optimizely/config/datafile_project_config.rb index cfb67f24..ecd02fd4 100644 --- a/lib/optimizely/config/datafile_project_config.rb +++ b/lib/optimizely/config/datafile_project_config.rb @@ -205,6 +205,28 @@ def initialize(datafile, logger, error_handler) feature_flag['experimentIds'].each do |experiment_id| @experiment_feature_map[experiment_id] = [feature_flag['id']] end + + # Feature Rollout support: inject the "everyone else" variation + # into any experiment with type == "feature_rollout" + everyone_else_variation = get_everyone_else_variation(feature_flag) + next if everyone_else_variation.nil? + + feature_flag['experimentIds'].each do |exp_id| + experiment = @experiment_id_map[exp_id] + next unless experiment && experiment['type'] == 'feature_rollout' + + experiment['variations'].push(everyone_else_variation) + experiment['trafficAllocation'].push( + 'entityId' => everyone_else_variation['id'], + 'endOfRange' => 10_000 + ) + @variation_key_map[experiment['key']][everyone_else_variation['key']] = everyone_else_variation + @variation_id_map[experiment['key']][everyone_else_variation['id']] = everyone_else_variation + @variation_id_map_by_experiment_id[exp_id][everyone_else_variation['id']] = everyone_else_variation + @variation_key_map_by_experiment_id[exp_id][everyone_else_variation['key']] = everyone_else_variation + variation_variables = everyone_else_variation['variables'] + @variation_id_to_variable_usage_map[everyone_else_variation['id']] = generate_key_map(variation_variables, 'id') if variation_variables + end end # Adding Holdout variations in variation id and key maps @@ -690,6 +712,38 @@ def get_holdout(holdout_id) private + def get_everyone_else_variation(feature_flag) + # Get the "everyone else" variation for a feature flag. + # + # The "everyone else" rule is the last experiment in the flag's rollout, + # and its first variation is the "everyone else" variation. + # + # feature_flag - Feature flag hash + # + # Returns the "everyone else" variation hash, or nil if not available. + + rollout_id = feature_flag['rolloutId'] + return nil if rollout_id.nil? || rollout_id.empty? + + rollout = @rollout_id_map[rollout_id] + return nil if rollout.nil? + + experiments = rollout['experiments'] + return nil if experiments.nil? || experiments.empty? + + everyone_else_rule = experiments.last + variations = everyone_else_rule['variations'] + return nil if variations.nil? || variations.empty? + + variation = variations.first + { + 'id' => variation['id'], + 'key' => variation['key'], + 'featureEnabled' => variation['featureEnabled'] == true, + 'variables' => variation.fetch('variables', []) + } + end + def generate_feature_variation_map(feature_flags) flag_variation_map = {} feature_flags.each do |flag| diff --git a/spec/config/datafile_project_config_spec.rb b/spec/config/datafile_project_config_spec.rb index ea47fe6f..a04c1342 100644 --- a/spec/config/datafile_project_config_spec.rb +++ b/spec/config/datafile_project_config_spec.rb @@ -1801,4 +1801,351 @@ end end end + + describe 'Feature Rollout support' do + def build_datafile(experiments: [], rollouts: [], feature_flags: []) + { + 'version' => '4', + 'accountId' => '12001', + 'projectId' => '111001', + 'revision' => '1', + 'experiments' => experiments, + 'events' => [], + 'attributes' => [], + 'audiences' => [], + 'groups' => [], + 'rollouts' => rollouts, + 'featureFlags' => feature_flags + } + end + + it 'should set experiment type to nil when type field is missing' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_ab', + 'key' => 'ab_test_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'var_1', 'endOfRange' => 5000}], + 'variations' => [{'key' => 'var_1', 'id' => 'var_1', 'featureEnabled' => true}] + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_ab'], + 'rolloutId' => '', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_ab'] + expect(experiment['type']).to be_nil + end + + it 'should inject everyone else variation into feature_rollout experiments' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_fr', + 'key' => 'feature_rollout_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'rollout_var', 'endOfRange' => 5000}], + 'variations' => [ + {'key' => 'rollout_var', 'id' => 'rollout_var', 'featureEnabled' => true} + ], + 'type' => 'feature_rollout' + } + ], + rollouts: [ + { + 'id' => 'rollout_1', + 'experiments' => [ + { + 'id' => 'rollout_targeted_rule', + 'key' => 'rollout_targeted_rule', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => ['audience_1'], + 'trafficAllocation' => [{'entityId' => 'targeted_var', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'targeted_var', 'id' => 'targeted_var', 'featureEnabled' => true} + ] + }, + { + 'id' => 'rollout_everyone_else', + 'key' => 'rollout_everyone_else', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'everyone_else_var', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'everyone_else_var', 'id' => 'everyone_else_var', 'featureEnabled' => false} + ] + } + ] + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_fr'], + 'rolloutId' => 'rollout_1', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_fr'] + + # Should now have 2 variations: original + everyone else + expect(experiment['variations'].length).to eq(2) + + variation_ids = experiment['variations'].map { |v| v['id'] } + expect(variation_ids).to include('everyone_else_var') + + # Verify traffic allocation was appended with endOfRange=10000 + expect(experiment['trafficAllocation'].length).to eq(2) + last_allocation = experiment['trafficAllocation'].last + expect(last_allocation['entityId']).to eq('everyone_else_var') + expect(last_allocation['endOfRange']).to eq(10_000) + end + + it 'should update all variation maps after injection' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_fr', + 'key' => 'feature_rollout_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'rollout_var', 'endOfRange' => 5000}], + 'variations' => [ + {'key' => 'rollout_var', 'id' => 'rollout_var', 'featureEnabled' => true} + ], + 'type' => 'feature_rollout' + } + ], + rollouts: [ + { + 'id' => 'rollout_1', + 'experiments' => [ + { + 'id' => 'rollout_everyone_else', + 'key' => 'rollout_everyone_else', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'everyone_else_var', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'everyone_else_var', 'id' => 'everyone_else_var', 'featureEnabled' => false} + ] + } + ] + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_fr'], + 'rolloutId' => 'rollout_1', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + + expect(config.variation_key_map['feature_rollout_exp']).to have_key('everyone_else_var') + expect(config.variation_id_map['feature_rollout_exp']).to have_key('everyone_else_var') + expect(config.variation_id_map_by_experiment_id['exp_fr']).to have_key('everyone_else_var') + expect(config.variation_key_map_by_experiment_id['exp_fr']).to have_key('everyone_else_var') + end + + it 'should not modify non-feature_rollout experiments' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_ab', + 'key' => 'ab_test_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'var_1', 'endOfRange' => 5000}], + 'variations' => [ + {'key' => 'var_1', 'id' => 'var_1', 'featureEnabled' => true} + ], + 'type' => 'a/b' + } + ], + rollouts: [ + { + 'id' => 'rollout_1', + 'experiments' => [ + { + 'id' => 'rollout_everyone_else', + 'key' => 'rollout_everyone_else', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'everyone_else_var', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'everyone_else_var', 'id' => 'everyone_else_var', 'featureEnabled' => false} + ] + } + ] + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_ab'], + 'rolloutId' => 'rollout_1', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_ab'] + + expect(experiment['variations'].length).to eq(1) + expect(experiment['trafficAllocation'].length).to eq(1) + end + + it 'should not modify feature_rollout experiment when rolloutId is empty' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_fr', + 'key' => 'feature_rollout_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'var_1', 'endOfRange' => 5000}], + 'variations' => [ + {'key' => 'var_1', 'id' => 'var_1', 'featureEnabled' => true} + ], + 'type' => 'feature_rollout' + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_fr'], + 'rolloutId' => '', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_fr'] + + expect(experiment['variations'].length).to eq(1) + expect(experiment['trafficAllocation'].length).to eq(1) + end + + it 'should use the LAST rollout rule for everyone else variation' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_fr', + 'key' => 'feature_rollout_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'fr_var', 'endOfRange' => 5000}], + 'variations' => [ + {'key' => 'fr_var', 'id' => 'fr_var', 'featureEnabled' => true} + ], + 'type' => 'feature_rollout' + } + ], + rollouts: [ + { + 'id' => 'rollout_1', + 'experiments' => [ + { + 'id' => 'targeted_rule_1', + 'key' => 'targeted_rule_1', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => ['aud_1'], + 'trafficAllocation' => [{'entityId' => 'targeted_var_1', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'targeted_var_1', 'id' => 'targeted_var_1', 'featureEnabled' => true} + ] + }, + { + 'id' => 'targeted_rule_2', + 'key' => 'targeted_rule_2', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => ['aud_2'], + 'trafficAllocation' => [{'entityId' => 'targeted_var_2', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'targeted_var_2', 'id' => 'targeted_var_2', 'featureEnabled' => true} + ] + }, + { + 'id' => 'everyone_else_rule', + 'key' => 'everyone_else_rule', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'correct_everyone_var', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'correct_everyone_var', 'id' => 'correct_everyone_var', 'featureEnabled' => false} + ] + } + ] + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_fr'], + 'rolloutId' => 'rollout_1', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_fr'] + + variation_ids = experiment['variations'].map { |v| v['id'] } + expect(variation_ids).to include('correct_everyone_var') + expect(variation_ids).not_to include('targeted_var_1') + expect(variation_ids).not_to include('targeted_var_2') + end + end end From 9185fef642cc83f7970f0d955d66d8c50dae6b68 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 5 Mar 2026 07:49:36 +0600 Subject: [PATCH 02/11] [AI-FSSDK] [FSSDK-12337] Add type field to experiment JSON schema Add optional 'type' string field to the experiment properties in the datafile JSON schema validation constants. --- lib/optimizely/helpers/constants.rb | 3 +++ 1 file changed, 3 insertions(+) diff --git a/lib/optimizely/helpers/constants.rb b/lib/optimizely/helpers/constants.rb index 4334f56d..869f402c 100644 --- a/lib/optimizely/helpers/constants.rb +++ b/lib/optimizely/helpers/constants.rb @@ -205,6 +205,9 @@ module Constants 'cmab' => { 'type' => 'object' }, + 'type' => { + 'type' => 'string' + }, 'holdouts' => { 'type' => 'array' } From 8971420eab9579a58a7d9ba04f36eaa309494407 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 5 Mar 2026 07:51:59 +0600 Subject: [PATCH 03/11] [AI-FSSDK] [FSSDK-12337] Add test for experiment type field parsing Verify that the type field from the datafile is correctly preserved on experiment hashes after config parsing. --- spec/config/datafile_project_config_spec.rb | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/spec/config/datafile_project_config_spec.rb b/spec/config/datafile_project_config_spec.rb index a04c1342..be7f73f2 100644 --- a/spec/config/datafile_project_config_spec.rb +++ b/spec/config/datafile_project_config_spec.rb @@ -1819,6 +1819,37 @@ def build_datafile(experiments: [], rollouts: [], feature_flags: []) } end + it 'should parse experiment type field from datafile' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_fr', + 'key' => 'feature_rollout_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'var_1', 'endOfRange' => 5000}], + 'variations' => [{'key' => 'var_1', 'id' => 'var_1', 'featureEnabled' => true}], + 'type' => 'feature_rollout' + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_fr'], + 'rolloutId' => '', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_fr'] + expect(experiment['type']).to eq('feature_rollout') + end + it 'should set experiment type to nil when type field is missing' do datafile = build_datafile( experiments: [ From deda391486b49c114d5f7ca8b3b24171756803d2 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 5 Mar 2026 08:20:58 +0600 Subject: [PATCH 04/11] [AI-FSSDK] [FSSDK-12337] Move flag_variation_map after rollout injection Move @flag_variation_map generation to after the feature rollout injection block so the everyone-else variation is included in get_variation_from_flag lookups used by forced decisions. --- lib/optimizely/config/datafile_project_config.rb | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lib/optimizely/config/datafile_project_config.rb b/lib/optimizely/config/datafile_project_config.rb index ecd02fd4..763e9fa6 100644 --- a/lib/optimizely/config/datafile_project_config.rb +++ b/lib/optimizely/config/datafile_project_config.rb @@ -180,7 +180,6 @@ def initialize(datafile, logger, error_handler) @all_segments.concat Audience.get_segments(audience['conditions']) end - @flag_variation_map = generate_feature_variation_map(@feature_flags) @all_experiments = @experiment_id_map.merge(@rollout_experiment_id_map) @all_experiments.each do |id, exp| variations = exp.fetch('variations') @@ -229,6 +228,9 @@ def initialize(datafile, logger, error_handler) end end + # Generate flag_variation_map after injection so it includes everyone-else variations + @flag_variation_map = generate_feature_variation_map(@feature_flags) + # Adding Holdout variations in variation id and key maps return unless @holdouts && !@holdouts.empty? From 9425dd218d171001d210950a213d029c841a2f2e Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 5 Mar 2026 08:22:23 +0600 Subject: [PATCH 05/11] [AI-FSSDK] [FSSDK-12337] Add tests for featureEnabled and variables propagation Add tests verifying: - Injected everyone-else variation preserves featureEnabled=false - Variables from the rollout variation carry through to the injected variation and populate variation_id_to_variable_usage_map --- spec/config/datafile_project_config_spec.rb | 122 ++++++++++++++++++++ 1 file changed, 122 insertions(+) diff --git a/spec/config/datafile_project_config_spec.rb b/spec/config/datafile_project_config_spec.rb index be7f73f2..506497d3 100644 --- a/spec/config/datafile_project_config_spec.rb +++ b/spec/config/datafile_project_config_spec.rb @@ -2178,5 +2178,127 @@ def build_datafile(experiments: [], rollouts: [], feature_flags: []) expect(variation_ids).not_to include('targeted_var_1') expect(variation_ids).not_to include('targeted_var_2') end + + it 'should preserve featureEnabled value on injected variation' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_fr', + 'key' => 'feature_rollout_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'rollout_var', 'endOfRange' => 5000}], + 'variations' => [ + {'key' => 'rollout_var', 'id' => 'rollout_var', 'featureEnabled' => true} + ], + 'type' => 'feature_rollout' + } + ], + rollouts: [ + { + 'id' => 'rollout_1', + 'experiments' => [ + { + 'id' => 'rollout_everyone_else', + 'key' => 'rollout_everyone_else', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'everyone_else_var', 'endOfRange' => 10_000}], + 'variations' => [ + {'key' => 'everyone_else_var', 'id' => 'everyone_else_var', 'featureEnabled' => false} + ] + } + ] + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_fr'], + 'rolloutId' => 'rollout_1', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_fr'] + + injected = experiment['variations'].find { |v| v['id'] == 'everyone_else_var' } + expect(injected).not_to be_nil + expect(injected['featureEnabled']).to eq(false) + end + + it 'should propagate variables from the everyone else variation' do + datafile = build_datafile( + experiments: [ + { + 'id' => 'exp_fr', + 'key' => 'feature_rollout_exp', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'layer_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'rollout_var', 'endOfRange' => 5000}], + 'variations' => [ + {'key' => 'rollout_var', 'id' => 'rollout_var', 'featureEnabled' => true} + ], + 'type' => 'feature_rollout' + } + ], + rollouts: [ + { + 'id' => 'rollout_1', + 'experiments' => [ + { + 'id' => 'rollout_everyone_else', + 'key' => 'rollout_everyone_else', + 'status' => 'Running', + 'forcedVariations' => {}, + 'layerId' => 'rollout_1', + 'audienceIds' => [], + 'trafficAllocation' => [{'entityId' => 'everyone_else_var', 'endOfRange' => 10_000}], + 'variations' => [ + { + 'key' => 'everyone_else_var', + 'id' => 'everyone_else_var', + 'featureEnabled' => false, + 'variables' => [ + {'id' => 'var_1', 'value' => 'default_value'} + ] + } + ] + } + ] + } + ], + feature_flags: [ + { + 'id' => 'flag_1', + 'key' => 'test_flag', + 'experimentIds' => ['exp_fr'], + 'rolloutId' => 'rollout_1', + 'variables' => [] + } + ] + ) + + config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) + experiment = config.experiment_id_map['exp_fr'] + + injected = experiment['variations'].find { |v| v['id'] == 'everyone_else_var' } + expect(injected).not_to be_nil + expect(injected['variables']).to eq([{'id' => 'var_1', 'value' => 'default_value'}]) + + # Verify variation_id_to_variable_usage_map is populated + variable_usage = config.variation_id_to_variable_usage_map['everyone_else_var'] + expect(variable_usage).not_to be_nil + expect(variable_usage).to have_key('var_1') + end end end From ef4116d55fa70c4876502e135ba06e883dfa0b8c Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Thu, 5 Mar 2026 08:29:54 +0600 Subject: [PATCH 06/11] [AI-FSSDK] [FSSDK-12337] Fix pre-existing rubocop Lint/Void offense in spec_params Remove redundant else clause in deep_clone that referenced a variable in void context (Lint/Void: Variable new_obj used in void context). --- spec/spec_params.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/spec/spec_params.rb b/spec/spec_params.rb index 824e2ac7..6d7ff5c3 100644 --- a/spec/spec_params.rb +++ b/spec/spec_params.rb @@ -2062,8 +2062,6 @@ def self.deep_clone(obj) new_obj.map! do |val| deep_clone(val) end - else - new_obj end end end From ba3e6be267f3db6b397ca4e0ba5f3234b0f62127 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Tue, 10 Mar 2026 23:16:03 +0600 Subject: [PATCH 07/11] [AI-FSSDK] [FSSDK-12337] Add EXPERIMENT_TYPES constant with enum validation - Define EXPERIMENT_TYPES constant with valid experiment type values (ab, mab, cmab, td, fr) in Constants module - Use enum constraint in JSON schema to validate the type field - Reference constant in injection check instead of raw string literal - Add flag_variation_map assertion to variation maps test --- lib/optimizely/config/datafile_project_config.rb | 2 +- lib/optimizely/helpers/constants.rb | 11 ++++++++++- spec/config/datafile_project_config_spec.rb | 5 +++++ 3 files changed, 16 insertions(+), 2 deletions(-) diff --git a/lib/optimizely/config/datafile_project_config.rb b/lib/optimizely/config/datafile_project_config.rb index 763e9fa6..cd47d535 100644 --- a/lib/optimizely/config/datafile_project_config.rb +++ b/lib/optimizely/config/datafile_project_config.rb @@ -212,7 +212,7 @@ def initialize(datafile, logger, error_handler) feature_flag['experimentIds'].each do |exp_id| experiment = @experiment_id_map[exp_id] - next unless experiment && experiment['type'] == 'feature_rollout' + next unless experiment && experiment['type'] == Helpers::Constants::EXPERIMENT_TYPES['fr'] experiment['variations'].push(everyone_else_variation) experiment['trafficAllocation'].push( diff --git a/lib/optimizely/helpers/constants.rb b/lib/optimizely/helpers/constants.rb index 869f402c..36c289d8 100644 --- a/lib/optimizely/helpers/constants.rb +++ b/lib/optimizely/helpers/constants.rb @@ -18,6 +18,14 @@ module Optimizely module Helpers module Constants + EXPERIMENT_TYPES = { + 'ab' => 'a/b', + 'mab' => 'mab', + 'cmab' => 'cmab', + 'td' => 'targeted_delivery', + 'fr' => 'feature_rollout' + }.freeze + JSON_SCHEMA_V2 = { 'type' => 'object', 'properties' => { @@ -206,7 +214,8 @@ module Constants 'type' => 'object' }, 'type' => { - 'type' => 'string' + 'type' => %w[string null], + 'enum' => EXPERIMENT_TYPES.values + [nil] }, 'holdouts' => { 'type' => 'array' diff --git a/spec/config/datafile_project_config_spec.rb b/spec/config/datafile_project_config_spec.rb index 506497d3..339c6f33 100644 --- a/spec/config/datafile_project_config_spec.rb +++ b/spec/config/datafile_project_config_spec.rb @@ -2008,6 +2008,11 @@ def build_datafile(experiments: [], rollouts: [], feature_flags: []) expect(config.variation_id_map['feature_rollout_exp']).to have_key('everyone_else_var') expect(config.variation_id_map_by_experiment_id['exp_fr']).to have_key('everyone_else_var') expect(config.variation_key_map_by_experiment_id['exp_fr']).to have_key('everyone_else_var') + + # flag_variation_map should also include the injected variation + flag_variations = config.flag_variation_map['test_flag'] + injected_ids = flag_variations.map { |v| v['id'] } + expect(injected_ids).to include('everyone_else_var') end it 'should not modify non-feature_rollout experiments' do From acb21dcb2964307c4f14b9d8cbc5da90f5a7b6bb Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Tue, 10 Mar 2026 23:37:49 +0600 Subject: [PATCH 08/11] [AI-FSSDK] [FSSDK-12337] Remove tests not in ticket spec Remove 3 tests not in updated Jira ticket test requirements: - LAST rollout rule selection - featureEnabled preservation - Variables propagation --- spec/config/datafile_project_config_spec.rb | 201 -------------------- 1 file changed, 201 deletions(-) diff --git a/spec/config/datafile_project_config_spec.rb b/spec/config/datafile_project_config_spec.rb index 339c6f33..5dece0c3 100644 --- a/spec/config/datafile_project_config_spec.rb +++ b/spec/config/datafile_project_config_spec.rb @@ -2104,206 +2104,5 @@ def build_datafile(experiments: [], rollouts: [], feature_flags: []) expect(experiment['trafficAllocation'].length).to eq(1) end - it 'should use the LAST rollout rule for everyone else variation' do - datafile = build_datafile( - experiments: [ - { - 'id' => 'exp_fr', - 'key' => 'feature_rollout_exp', - 'status' => 'Running', - 'forcedVariations' => {}, - 'layerId' => 'layer_1', - 'audienceIds' => [], - 'trafficAllocation' => [{'entityId' => 'fr_var', 'endOfRange' => 5000}], - 'variations' => [ - {'key' => 'fr_var', 'id' => 'fr_var', 'featureEnabled' => true} - ], - 'type' => 'feature_rollout' - } - ], - rollouts: [ - { - 'id' => 'rollout_1', - 'experiments' => [ - { - 'id' => 'targeted_rule_1', - 'key' => 'targeted_rule_1', - 'status' => 'Running', - 'forcedVariations' => {}, - 'layerId' => 'rollout_1', - 'audienceIds' => ['aud_1'], - 'trafficAllocation' => [{'entityId' => 'targeted_var_1', 'endOfRange' => 10_000}], - 'variations' => [ - {'key' => 'targeted_var_1', 'id' => 'targeted_var_1', 'featureEnabled' => true} - ] - }, - { - 'id' => 'targeted_rule_2', - 'key' => 'targeted_rule_2', - 'status' => 'Running', - 'forcedVariations' => {}, - 'layerId' => 'rollout_1', - 'audienceIds' => ['aud_2'], - 'trafficAllocation' => [{'entityId' => 'targeted_var_2', 'endOfRange' => 10_000}], - 'variations' => [ - {'key' => 'targeted_var_2', 'id' => 'targeted_var_2', 'featureEnabled' => true} - ] - }, - { - 'id' => 'everyone_else_rule', - 'key' => 'everyone_else_rule', - 'status' => 'Running', - 'forcedVariations' => {}, - 'layerId' => 'rollout_1', - 'audienceIds' => [], - 'trafficAllocation' => [{'entityId' => 'correct_everyone_var', 'endOfRange' => 10_000}], - 'variations' => [ - {'key' => 'correct_everyone_var', 'id' => 'correct_everyone_var', 'featureEnabled' => false} - ] - } - ] - } - ], - feature_flags: [ - { - 'id' => 'flag_1', - 'key' => 'test_flag', - 'experimentIds' => ['exp_fr'], - 'rolloutId' => 'rollout_1', - 'variables' => [] - } - ] - ) - - config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) - experiment = config.experiment_id_map['exp_fr'] - - variation_ids = experiment['variations'].map { |v| v['id'] } - expect(variation_ids).to include('correct_everyone_var') - expect(variation_ids).not_to include('targeted_var_1') - expect(variation_ids).not_to include('targeted_var_2') - end - - it 'should preserve featureEnabled value on injected variation' do - datafile = build_datafile( - experiments: [ - { - 'id' => 'exp_fr', - 'key' => 'feature_rollout_exp', - 'status' => 'Running', - 'forcedVariations' => {}, - 'layerId' => 'layer_1', - 'audienceIds' => [], - 'trafficAllocation' => [{'entityId' => 'rollout_var', 'endOfRange' => 5000}], - 'variations' => [ - {'key' => 'rollout_var', 'id' => 'rollout_var', 'featureEnabled' => true} - ], - 'type' => 'feature_rollout' - } - ], - rollouts: [ - { - 'id' => 'rollout_1', - 'experiments' => [ - { - 'id' => 'rollout_everyone_else', - 'key' => 'rollout_everyone_else', - 'status' => 'Running', - 'forcedVariations' => {}, - 'layerId' => 'rollout_1', - 'audienceIds' => [], - 'trafficAllocation' => [{'entityId' => 'everyone_else_var', 'endOfRange' => 10_000}], - 'variations' => [ - {'key' => 'everyone_else_var', 'id' => 'everyone_else_var', 'featureEnabled' => false} - ] - } - ] - } - ], - feature_flags: [ - { - 'id' => 'flag_1', - 'key' => 'test_flag', - 'experimentIds' => ['exp_fr'], - 'rolloutId' => 'rollout_1', - 'variables' => [] - } - ] - ) - - config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) - experiment = config.experiment_id_map['exp_fr'] - - injected = experiment['variations'].find { |v| v['id'] == 'everyone_else_var' } - expect(injected).not_to be_nil - expect(injected['featureEnabled']).to eq(false) - end - - it 'should propagate variables from the everyone else variation' do - datafile = build_datafile( - experiments: [ - { - 'id' => 'exp_fr', - 'key' => 'feature_rollout_exp', - 'status' => 'Running', - 'forcedVariations' => {}, - 'layerId' => 'layer_1', - 'audienceIds' => [], - 'trafficAllocation' => [{'entityId' => 'rollout_var', 'endOfRange' => 5000}], - 'variations' => [ - {'key' => 'rollout_var', 'id' => 'rollout_var', 'featureEnabled' => true} - ], - 'type' => 'feature_rollout' - } - ], - rollouts: [ - { - 'id' => 'rollout_1', - 'experiments' => [ - { - 'id' => 'rollout_everyone_else', - 'key' => 'rollout_everyone_else', - 'status' => 'Running', - 'forcedVariations' => {}, - 'layerId' => 'rollout_1', - 'audienceIds' => [], - 'trafficAllocation' => [{'entityId' => 'everyone_else_var', 'endOfRange' => 10_000}], - 'variations' => [ - { - 'key' => 'everyone_else_var', - 'id' => 'everyone_else_var', - 'featureEnabled' => false, - 'variables' => [ - {'id' => 'var_1', 'value' => 'default_value'} - ] - } - ] - } - ] - } - ], - feature_flags: [ - { - 'id' => 'flag_1', - 'key' => 'test_flag', - 'experimentIds' => ['exp_fr'], - 'rolloutId' => 'rollout_1', - 'variables' => [] - } - ] - ) - - config = Optimizely::DatafileProjectConfig.new(JSON.dump(datafile), logger, error_handler) - experiment = config.experiment_id_map['exp_fr'] - - injected = experiment['variations'].find { |v| v['id'] == 'everyone_else_var' } - expect(injected).not_to be_nil - expect(injected['variables']).to eq([{'id' => 'var_1', 'value' => 'default_value'}]) - - # Verify variation_id_to_variable_usage_map is populated - variable_usage = config.variation_id_to_variable_usage_map['everyone_else_var'] - expect(variable_usage).not_to be_nil - expect(variable_usage).to have_key('var_1') - end end end From 50298ca0a0da9610a4ea1be44de47759c2cafbc1 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Tue, 10 Mar 2026 23:41:40 +0600 Subject: [PATCH 09/11] [AI-FSSDK] [FSSDK-12337] Fix rubocop EmptyLinesAroundBlockBody offense --- spec/config/datafile_project_config_spec.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/spec/config/datafile_project_config_spec.rb b/spec/config/datafile_project_config_spec.rb index 5dece0c3..0687bff2 100644 --- a/spec/config/datafile_project_config_spec.rb +++ b/spec/config/datafile_project_config_spec.rb @@ -2103,6 +2103,5 @@ def build_datafile(experiments: [], rollouts: [], feature_flags: []) expect(experiment['variations'].length).to eq(1) expect(experiment['trafficAllocation'].length).to eq(1) end - end end From cd6cc6203a4ee786fbdf4e5dfd4ee569ef7162ff Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 11 Mar 2026 00:48:52 +0600 Subject: [PATCH 10/11] [FSSDK-12337] Fix experiment type values to match backend Update EXPERIMENT_TYPES to use actual backend values: 'multi_armed_bandit' and 'contextual_multi_armed_bandit' instead of shorthand 'mab' and 'cmab'. Restore accidentally removed else branch in spec_params deep_clone. --- lib/optimizely/helpers/constants.rb | 4 ++-- spec/spec_params.rb | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/optimizely/helpers/constants.rb b/lib/optimizely/helpers/constants.rb index 36c289d8..6508648b 100644 --- a/lib/optimizely/helpers/constants.rb +++ b/lib/optimizely/helpers/constants.rb @@ -20,8 +20,8 @@ module Helpers module Constants EXPERIMENT_TYPES = { 'ab' => 'a/b', - 'mab' => 'mab', - 'cmab' => 'cmab', + 'mab' => 'multi_armed_bandit', + 'cmab' => 'contextual_multi_armed_bandit', 'td' => 'targeted_delivery', 'fr' => 'feature_rollout' }.freeze diff --git a/spec/spec_params.rb b/spec/spec_params.rb index 6d7ff5c3..824e2ac7 100644 --- a/spec/spec_params.rb +++ b/spec/spec_params.rb @@ -2062,6 +2062,8 @@ def self.deep_clone(obj) new_obj.map! do |val| deep_clone(val) end + else + new_obj end end end From ba269921460d47842a071ddfff13474169b16302 Mon Sep 17 00:00:00 2001 From: FarhanAnjum-opti Date: Wed, 11 Mar 2026 22:22:27 +0600 Subject: [PATCH 11/11] [AI-FSSDK] [FSSDK-12337] Fix rubocop Lint/Void offense in spec_params Remove redundant else clause in deep_clone that was accidentally restored in a previous commit. --- spec/spec_params.rb | 2 -- 1 file changed, 2 deletions(-) diff --git a/spec/spec_params.rb b/spec/spec_params.rb index 824e2ac7..6d7ff5c3 100644 --- a/spec/spec_params.rb +++ b/spec/spec_params.rb @@ -2062,8 +2062,6 @@ def self.deep_clone(obj) new_obj.map! do |val| deep_clone(val) end - else - new_obj end end end