Skip to content

Commit 1652249

Browse files
Mat001claudeesrakartalOpt
authored
[AI-FSSDK] [FSSDK-12369] Add local holdouts support with includedRules field (#398)
* [AI-FSSDK] [FSSDK-12369] Add local holdouts support with includedRules field * Fix Rubocop offenses in local holdouts spec files - Fix Layout/SpaceInsideHashLiteralBraces in datafile_project_config_spec.rb - Fix Lint/UselessAssignment by removing unused feature_flag variable in decision_service_holdout_spec.rb Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> * [AI-FSSDK] [FSSDK-12369] Fix rubocop offenses: rename get_global_holdouts, use next pattern * [FSSDK-12369] Add mandatory enforcement test: forced decision beats 100% local holdout * Empty commit to retrigger FSC * Empty commit to trigger FSC * Fix unit test issue * Empty commit to trigger FSC * Fix decision holdout test failures * Update comment for holdout decision logic Corrected comment to reflect global holdout decision handling. --------- Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com> Co-authored-by: esrakartalOpt <esra.kartal@optimizely.com> Co-authored-by: esrakartalOpt <102107327+esrakartalOpt@users.noreply.github.com>
1 parent 3f95052 commit 1652249

6 files changed

Lines changed: 617 additions & 10 deletions

File tree

lib/optimizely/config/datafile_project_config.rb

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ class DatafileProjectConfig < ProjectConfig
3333
:group_id_map, :rollout_id_map, :rollout_experiment_id_map, :variation_id_map,
3434
:variation_id_to_variable_usage_map, :variation_key_map, :variation_id_map_by_experiment_id,
3535
:variation_key_map_by_experiment_id, :flag_variation_map, :integration_key_map, :integrations,
36-
:public_key_for_odp, :host_for_odp, :all_segments, :region, :holdouts, :holdout_id_map
36+
:public_key_for_odp, :host_for_odp, :all_segments, :region, :holdouts, :holdout_id_map,
37+
:global_holdouts, :rule_holdouts_map
3738
# Boolean - denotes if Optimizely should remove the last block of visitors' IP address before storing event data
3839
attr_reader :anonymize_ip
3940

@@ -114,6 +115,8 @@ def initialize(datafile, logger, error_handler)
114115
@variation_id_to_experiment_map = {}
115116
@flag_variation_map = {}
116117
@holdout_id_map = {}
118+
@global_holdouts = []
119+
@rule_holdouts_map = {}
117120

118121
@holdouts.each do |holdout|
119122
next unless holdout['status'] == 'Running'
@@ -122,6 +125,19 @@ def initialize(datafile, logger, error_handler)
122125
holdout['layerId'] ||= ''
123126

124127
@holdout_id_map[holdout['id']] = holdout
128+
129+
# Build global vs local holdout mappings
130+
# A holdout is global when includedRules is nil/absent (applies to all rules)
131+
# A holdout is local when includedRules is a non-nil array (applies only to specified rules)
132+
if holdout_global?(holdout)
133+
@global_holdouts << holdout
134+
else
135+
included_rules = holdout['includedRules'] || []
136+
included_rules.each do |rule_id|
137+
@rule_holdouts_map[rule_id] ||= []
138+
@rule_holdouts_map[rule_id] << holdout
139+
end
140+
end
125141
end
126142

127143
@experiment_id_map.each_value do |exp|
@@ -642,6 +658,27 @@ def get_holdout(holdout_id)
642658
nil
643659
end
644660

661+
def get_holdouts_for_rule(rule_id)
662+
# Returns running local holdouts that target a specific rule ID.
663+
# Local holdouts apply only to the rules listed in their includedRules array.
664+
#
665+
# rule_id - String ID of the experiment/delivery rule
666+
#
667+
# Returns Array of holdout hashes targeting the rule (empty array if none)
668+
@rule_holdouts_map[rule_id] || []
669+
end
670+
671+
def holdout_global?(holdout)
672+
# Determines whether a holdout is global (applies to all rules) or local (applies to specific rules).
673+
# A holdout is global when includedRules is nil or absent from the datafile.
674+
# A holdout with an empty array [] is a local holdout with no matching rules (NOT global).
675+
#
676+
# holdout - Holdout hash from the datafile
677+
#
678+
# Returns true if the holdout is global, false if local
679+
!holdout.key?('includedRules') || holdout['includedRules'].nil?
680+
end
681+
645682
private
646683

647684
def get_everyone_else_variation(feature_flag)

lib/optimizely/decision_service.rb

Lines changed: 38 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ class DecisionService
4040

4141
Decision = Struct.new(:experiment, :variation, :source, :cmab_uuid)
4242
CmabDecisionResult = Struct.new(:error, :result, :reasons)
43-
VariationResult = Struct.new(:cmab_uuid, :error, :reasons, :variation_id)
43+
VariationResult = Struct.new(:cmab_uuid, :error, :reasons, :variation_id, :holdout_decision)
4444
DecisionResult = Struct.new(:decision, :error, :reasons)
4545

4646
DECISION_SOURCES = {
@@ -169,7 +169,7 @@ def get_variation_for_feature(project_config, feature_flag, user_context, decide
169169
# user_context - Optimizely user context instance
170170
#
171171
# Returns DecisionResult struct.
172-
# Get running holdouts from the holdout_id_map (all holdouts are global now)
172+
# Check for any running holdouts (global or local)
173173
running_holdouts = project_config.holdout_id_map.values
174174

175175
if running_holdouts && !running_holdouts.empty?
@@ -196,8 +196,8 @@ def get_decision_for_flag(feature_flag, user_context, project_config, decide_opt
196196
reasons = decide_reasons ? decide_reasons.dup : []
197197
user_id = user_context.user_id
198198

199-
# Check holdouts (all holdouts are global now - apply to all flags)
200-
holdouts = project_config.holdout_id_map.values
199+
# Check global holdouts first (flag level) — these apply to all rules across all flags
200+
holdouts = project_config.global_holdouts
201201

202202
holdouts.each do |holdout|
203203
holdout_decision = get_variation_for_holdout(holdout, user_context, project_config)
@@ -365,6 +365,9 @@ def get_variation_for_feature_experiment(project_config, feature_flag, user_cont
365365
# If there's an error, return immediately instead of falling back to next experiment
366366
return DecisionResult.new(nil, error, decide_reasons) if error
367367

368+
# If a global holdout decision was made, return it directly
369+
return DecisionResult.new(variation_result.holdout_decision, false, decide_reasons) if variation_result.holdout_decision
370+
368371
next unless variation_id
369372

370373
variation = project_config.get_variation_from_id_by_experiment_id(experiment_id, variation_id)
@@ -415,8 +418,11 @@ def get_variation_for_feature_rollout(project_config, feature_flag, user_context
415418
index = 0
416419
rollout_rules = rollout['experiments']
417420
while index < rollout_rules.length
418-
variation, skip_to_everyone_else, reasons_received = get_variation_from_delivery_rule(project_config, feature_flag_key, rollout_rules, index, user_context)
421+
holdout_decision, variation, skip_to_everyone_else, reasons_received = get_variation_from_delivery_rule(project_config, feature_flag_key, rollout_rules, index, user_context)
419422
decide_reasons.push(*reasons_received)
423+
424+
return DecisionResult.new(holdout_decision, false, decide_reasons) if holdout_decision
425+
420426
if variation
421427
rule = rollout_rules[index]
422428
feature_decision = Decision.new(rule, variation, DECISION_SOURCES['ROLLOUT'], nil)
@@ -441,11 +447,24 @@ def get_variation_from_experiment_rule(project_config, flag_key, rule, user, use
441447
# Returns variation_id and reasons
442448
reasons = []
443449

450+
# Step 1: Forced decision check
444451
context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
445452
variation, forced_reasons = validated_forced_decision(project_config, context, user)
446453
reasons.push(*forced_reasons)
447454
return VariationResult.new(nil, false, reasons, variation['id']) if variation
448455

456+
# Step 2: Local holdout check
457+
local_holdouts = project_config.get_holdouts_for_rule(rule['id'])
458+
local_holdouts.each do |holdout|
459+
holdout_decision = get_variation_for_holdout(holdout, user, project_config)
460+
reasons.push(*holdout_decision.reasons)
461+
next unless holdout_decision.decision
462+
463+
holdout_variation = holdout_decision.decision.variation
464+
return VariationResult.new(nil, false, reasons, holdout_variation['id'], holdout_decision.decision)
465+
end
466+
467+
# Step 3: Regular rule evaluation
449468
variation_result = get_variation(project_config, rule['id'], user, user_profile_tracker, options)
450469
variation_result.reasons = reasons + variation_result.reasons
451470
variation_result
@@ -460,16 +479,26 @@ def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index
460479
# rule - An experiment rule key
461480
# user_context - Optimizely user context instance
462481
#
463-
# Returns variation, boolean to skip for eveyone else rule and reasons
482+
# Returns [holdout_decision, variation, skip_to_everyone_else, reasons]
464483
reasons = []
465484
skip_to_everyone_else = false
466485
rule = rules[rule_index]
486+
487+
# Step 1: Forced decision check
467488
context = Optimizely::OptimizelyUserContext::OptimizelyDecisionContext.new(flag_key, rule['key'])
468489
variation, forced_reasons = validated_forced_decision(project_config, context, user_context)
469490
reasons.push(*forced_reasons)
491+
return [nil, variation, skip_to_everyone_else, reasons] if variation
470492

471-
return [variation, skip_to_everyone_else, reasons] if variation
493+
# Step 2: Local holdout check
494+
local_holdouts = project_config.get_holdouts_for_rule(rule['id'])
495+
local_holdouts.each do |holdout|
496+
holdout_decision = get_variation_for_holdout(holdout, user_context, project_config)
497+
reasons.push(*holdout_decision.reasons)
498+
return [holdout_decision.decision, nil, skip_to_everyone_else, reasons] if holdout_decision.decision
499+
end
472500

501+
# Step 3: Regular rule evaluation
473502
user_id = user_context.user_id
474503
attributes = user_context.user_attributes
475504
bucketing_id, bucketing_id_reasons = get_bucketing_id(user_id, attributes)
@@ -485,7 +514,7 @@ def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index
485514
message = "User '#{user_id}' does not meet the conditions for targeting rule '#{logging_key}'."
486515
@logger.log(Logger::DEBUG, message)
487516
reasons.push(message)
488-
return [nil, skip_to_everyone_else, reasons]
517+
return [nil, nil, skip_to_everyone_else, reasons]
489518
end
490519

491520
message = "User '#{user_id}' meets the audience conditions for targeting rule '#{logging_key}'."
@@ -505,7 +534,7 @@ def get_variation_from_delivery_rule(project_config, flag_key, rules, rule_index
505534
reasons.push(message)
506535
skip_to_everyone_else = true
507536
end
508-
[bucket_variation, skip_to_everyone_else, reasons]
537+
[nil, bucket_variation, skip_to_everyone_else, reasons]
509538
end
510539

511540
def set_forced_variation(project_config, experiment_key, user_id, variation_key)

lib/optimizely/helpers/constants.rb

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,9 @@ module Constants
346346
},
347347
'status' => {
348348
'type' => 'string'
349+
},
350+
'includedRules' => {
351+
'type' => %w[array null]
349352
}
350353
}
351354
}

spec/config/datafile_project_config_spec.rb

Lines changed: 159 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1935,4 +1935,163 @@ def build_datafile(experiments: [], rollouts: [], feature_flags: [])
19351935
expect(experiment['trafficAllocation'].length).to eq(1)
19361936
end
19371937
end
1938+
1939+
# Level 1 — Local Holdouts config/parsing tests (FSSDK-12369)
1940+
describe 'local holdouts data model and config parsing' do
1941+
let(:config_with_local_holdouts) do
1942+
Optimizely::DatafileProjectConfig.new(
1943+
OptimizelySpec::CONFIG_BODY_WITH_HOLDOUTS_JSON,
1944+
logger,
1945+
error_handler
1946+
)
1947+
end
1948+
1949+
describe '#holdout_global?' do
1950+
it 'returns true for holdout with no includedRules key (old datafile format)' do
1951+
global_holdout = config_with_local_holdouts.get_holdout('holdout_1')
1952+
expect(global_holdout).not_to be_nil
1953+
expect(global_holdout.key?('includedRules')).to be false
1954+
expect(config_with_local_holdouts.holdout_global?(global_holdout)).to be true
1955+
end
1956+
1957+
it 'returns true for holdout with explicit nil includedRules' do
1958+
holdout = {'id' => 'test', 'key' => 'test', 'includedRules' => nil}
1959+
expect(config_with_local_holdouts.holdout_global?(holdout)).to be true
1960+
end
1961+
1962+
it 'returns false for holdout with non-nil includedRules array (local holdout)' do
1963+
local_holdout = config_with_local_holdouts.get_holdout('holdout_local_1')
1964+
expect(local_holdout).not_to be_nil
1965+
expect(config_with_local_holdouts.holdout_global?(local_holdout)).to be false
1966+
end
1967+
1968+
it 'returns false for holdout with empty includedRules array (local holdout with no matching rules)' do
1969+
local_holdout_empty = config_with_local_holdouts.get_holdout('holdout_local_empty_rules')
1970+
expect(local_holdout_empty).not_to be_nil
1971+
expect(local_holdout_empty['includedRules']).to eq([])
1972+
# Empty array is local holdout (not global) — DIFFERENT from nil
1973+
expect(config_with_local_holdouts.holdout_global?(local_holdout_empty)).to be false
1974+
end
1975+
end
1976+
1977+
describe '#global_holdouts' do
1978+
it 'returns only holdouts without includedRules (global holdouts)' do
1979+
global_holdouts = config_with_local_holdouts.global_holdouts
1980+
1981+
expect(global_holdouts).not_to be_nil
1982+
expect(global_holdouts).to be_an(Array)
1983+
1984+
# All returned holdouts must be global
1985+
global_holdouts.each do |holdout|
1986+
expect(config_with_local_holdouts.holdout_global?(holdout)).to be true
1987+
end
1988+
end
1989+
1990+
it 'does not include local holdouts (those with includedRules array)' do
1991+
global_holdouts = config_with_local_holdouts.global_holdouts
1992+
local_holdout = config_with_local_holdouts.get_holdout('holdout_local_1')
1993+
1994+
expect(global_holdouts).not_to include(local_holdout)
1995+
end
1996+
1997+
it 'does not include holdouts with empty includedRules array' do
1998+
global_holdouts = config_with_local_holdouts.global_holdouts
1999+
empty_local_holdout = config_with_local_holdouts.get_holdout('holdout_local_empty_rules')
2000+
2001+
# Empty [] is local, not global — must not appear in global_holdouts
2002+
expect(global_holdouts).not_to include(empty_local_holdout)
2003+
end
2004+
2005+
it 'returns empty array when no global holdouts are present' do
2006+
config_no_global = Optimizely::DatafileProjectConfig.new(
2007+
JSON.dump(
2008+
OptimizelySpec::VALID_CONFIG_BODY.merge(
2009+
'holdouts' => [
2010+
{
2011+
'id' => 'only_local',
2012+
'key' => 'only_local_holdout',
2013+
'status' => 'Running',
2014+
'audiences' => [],
2015+
'includedRules' => ['some_rule_id'],
2016+
'variations' => [{'id' => 'v1', 'key' => 'holdout', 'featureEnabled' => false}],
2017+
'trafficAllocation' => [{'entityId' => 'v1', 'endOfRange' => 10_000}]
2018+
}
2019+
]
2020+
)
2021+
),
2022+
logger,
2023+
error_handler
2024+
)
2025+
expect(config_no_global.global_holdouts).to eq([])
2026+
end
2027+
end
2028+
2029+
describe '#get_holdouts_for_rule' do
2030+
it 'returns local holdouts targeting the specified rule ID' do
2031+
rule_holdouts = config_with_local_holdouts.get_holdouts_for_rule('122227')
2032+
local_holdout = config_with_local_holdouts.get_holdout('holdout_local_1')
2033+
2034+
expect(rule_holdouts).to include(local_holdout)
2035+
end
2036+
2037+
it 'returns empty array for a rule ID with no targeting holdouts' do
2038+
rule_holdouts = config_with_local_holdouts.get_holdouts_for_rule('unknown_rule_id_xyz')
2039+
expect(rule_holdouts).to eq([])
2040+
end
2041+
2042+
it 'does not return global holdouts (those without includedRules)' do
2043+
rule_holdouts = config_with_local_holdouts.get_holdouts_for_rule('122227')
2044+
global_holdout = config_with_local_holdouts.get_holdout('holdout_1')
2045+
2046+
# Global holdout must not appear in rule-specific list
2047+
expect(rule_holdouts).not_to include(global_holdout)
2048+
end
2049+
2050+
it 'does not return holdouts targeting other rules' do
2051+
# holdout_local_2 targets rule 122238, not 122227
2052+
holdouts_for_rule_a = config_with_local_holdouts.get_holdouts_for_rule('122227')
2053+
local_holdout_2 = config_with_local_holdouts.get_holdout('holdout_local_2')
2054+
2055+
expect(holdouts_for_rule_a).not_to include(local_holdout_2)
2056+
end
2057+
2058+
it 'returns empty array for holdout with empty includedRules — does not match any rule' do
2059+
# holdout_local_empty_rules has includedRules: [] — should not appear for any rule
2060+
rule_holdouts = config_with_local_holdouts.get_holdouts_for_rule('122227')
2061+
empty_rules_holdout = config_with_local_holdouts.get_holdout('holdout_local_empty_rules')
2062+
2063+
expect(rule_holdouts).not_to include(empty_rules_holdout)
2064+
end
2065+
end
2066+
2067+
describe 'backward compatibility — old datafiles without includedRules' do
2068+
it 'parses holdouts without includedRules field as global (backward compatible)' do
2069+
# Use the config with only global holdouts (no includedRules key)
2070+
config_global_only = Optimizely::DatafileProjectConfig.new(
2071+
OptimizelySpec::CONFIG_BODY_WITH_GLOBAL_HOLDOUTS_ONLY_JSON,
2072+
logger,
2073+
error_handler
2074+
)
2075+
2076+
holdout = config_global_only.get_holdout('global_holdout_only_1')
2077+
expect(holdout).not_to be_nil
2078+
expect(holdout.key?('includedRules')).to be false
2079+
2080+
# Must be classified as global
2081+
expect(config_global_only.holdout_global?(holdout)).to be true
2082+
expect(config_global_only.global_holdouts).to include(holdout)
2083+
expect(config_global_only.get_holdouts_for_rule('any_rule')).to eq([])
2084+
end
2085+
2086+
it 'does not raise errors when processing old datafiles' do
2087+
expect do
2088+
Optimizely::DatafileProjectConfig.new(
2089+
OptimizelySpec::CONFIG_BODY_WITH_GLOBAL_HOLDOUTS_ONLY_JSON,
2090+
logger,
2091+
error_handler
2092+
)
2093+
end.not_to raise_error
2094+
end
2095+
end
2096+
end
19382097
end

0 commit comments

Comments
 (0)