Skip to content

Commit bb30e08

Browse files
Mat001claudemuzahidul-opti
authored
[FSSDK-12394] Add local holdouts to swift-sdk (ref sdk) (#628)
* [FSSDK-12394] Implement Local Holdouts support This commit implements Local Holdouts functionality that allows holdouts to target specific rules instead of all rules within a flag. Changes: - Holdout.swift: Replace includedFlags/excludedFlags with includedRules - HoldoutConfig.swift: Replace flag-level maps with ruleHoldoutsMap - ProjectConfig.swift: Add getGlobalHoldouts() and getHoldoutsForRule() - DefaultDecisionService.swift: Update decision logic for global/local holdouts * getDecisionForFlag() now uses only global holdouts * Added local holdout checks to getVariationFromExperimentRule() * Added local holdout checks to getVariationFromDeliveryRule() Datafile changes: - Global holdouts: includedRules == nil (applies to all rules) - Local holdouts: includedRules == [ruleId, ...] (specific rules only) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * [FSSDK-12394] Update unit tests for Local Holdouts Updates existing holdout tests to use includedRules instead of includedFlags/excludedFlags: - HoldoutTests.swift: Update sample data and decode tests * Replace sampleDataWithIncludedFlags with sampleDataWithIncludedRules * Replace sampleDataWithExcludedFlags with sampleDataWithDifferentRules * Add tests for isGlobal property - HoldoutConfigTests.swift: Complete rewrite for new model * Test getGlobalHoldouts() returns only global holdouts * Test getHoldoutsForRule() returns local holdouts for specific rules * Test multiple holdouts can target the same rule * Test rule-to-holdout mapping is built correctly * Remove tests for removed flag-level targeting functionality All tests verify the new Local Holdouts behavior: - Global holdouts: includedRules == nil - Local holdouts: includedRules == [ruleId, ...] Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * [FSSDK-12394] Add integration tests for Local Holdouts decision logic Added comprehensive integration tests covering: - Global holdout evaluation before all rules - Local holdout evaluation at experiment and delivery rule level - Multiple holdouts targeting same rule - Cross-flag holdout targeting - Global and local holdout interaction - Edge cases (inactive status, non-existent rules, empty includedRules) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * [FSSDK-12394] Update all tests to use includedRules instead of includedFlags/excludedFlags Migrated all test files from flag-level to rule-level holdout targeting: - Replaced includedFlags/excludedFlags with includedRules - Updated sample data to use rule IDs instead of flag IDs - Replaced getHoldoutForFlag() calls with getHoldoutsForRule() - Updated ProjectConfigTests to test new rule-level mapping logic Migration strategy: - includedFlags: [] + excludedFlags: [] → omit includedRules (nil = global) - includedFlags: [flagId] → includedRules: [all rule IDs in that flag] - excludedFlags: [flagId] → includedRules: [] (empty = local with no rules) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * [FSSDK-12394] Fix test failures by updating HoldoutConfig when modifying holdouts When tests modify project.holdouts, they must also update holdoutConfig.allHoldouts to trigger the internal map rebuilding (ruleHoldoutsMap). Without this, local holdouts are not properly indexed by rule ID and won't be evaluated during decision-making. Added `config.holdoutConfig.allHoldouts = [...]` after each `config.project.holdouts = [...]` assignment in all test files. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Fix local holdout test failures - Fix testDecideAll_with_holdout_excluded_flags: Changed includedRules from empty array to all feature_1 rule IDs, and updated feature_3 expectations to nil since feature_3 has no rules for local holdouts to target - Fix testDecideAll_with_multiple_holdouts: Removed excludedHoldout which had includedRules=[] (targets no rules), updated feature_3 expectations to nil since local holdouts cannot apply to features with no rules - Fix DecisionListenerTest_Holdouts setUp(): Added missing holdoutConfig.allHoldouts assignment to trigger map rebuild Feature_3 has no experiments or rollout rules, so local holdouts (rule-level targeting) cannot apply to it. Only global holdouts can apply to flags with no rules. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Fix local holdout source and experiment tracking Problem: When local holdouts returned variations, they were being converted to FeatureDecisions with the experiment (not holdout) and source "feature-test" (not "holdout"), causing tests to fail. Root cause: VariationDecision struct didn't carry information about whether the variation came from a holdout. Solution: - Added 'holdout' field to VariationDecision struct - Created DeliveryRuleDecision struct for delivery rules with holdout info - Updated getVariationFromExperimentRule() to set holdout when returning holdout variation - Updated getVariationFromDeliveryRule() to use DeliveryRuleDecision and set holdout field - Updated getVariationForFeatureExperiments() to check for holdout and create FeatureDecision with holdout + source "holdout" instead of experiment + source "feature-test" - Updated getVariationForFeatureRollout() to handle DeliveryRuleDecision and check for holdout This ensures holdout decisions are properly tracked through the decision flow and returned with correct experiment ID and source. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * Fix bucketing boundary issue in global holdout tests Problem: testGetVariationForFeatureExperiment_NoExperiments and testGetVariationForFeatureExperiment_InvalidExperimentIds were failing because users weren't bucketing into global holdouts. Root cause: Tests used mockBucketValue: 500 (from setUp) but sampleHoldoutGlobal has endOfRange: 500, meaning the valid range is 0-499 (exclusive of 500). Bucket value 500 is outside this range. Solution: Create new MockDecisionService instances in these tests with mockBucketValue: 400, which is within the global holdout range. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * [FSSDK-12394] Fix stub tests to fail by default instead of passing Replace XCTAssertTrue(true, ...) with XCTFail(...) for all unimplemented test stubs. This ensures unimplemented tests are visible in test results rather than silently passing. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * [FSSDK-12394] Add DecisionServiceTests_LocalHoldouts to Xcode project Add the test file to OptimizelySwiftSDK.xcodeproj so tests run in Xcode builds. File was created on filesystem but not linked in the Xcode project, causing tests to be skipped in Xcode. - Added PBXBuildFile entries for both test targets - Added PBXFileReference entry - Added to test target sources Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * [FSSDK-12394] Implement Local Holdouts integration tests Implemented 13 integration tests for local holdouts functionality: Global Holdouts: - testGlobalHoldout_EvaluatedBeforeAllRules - testGlobalHoldout_MissAllowsRuleEvaluation Local Holdouts - Experiment Rules: - testLocalHoldout_ExperimentRule_UserBucketed - testLocalHoldout_ExperimentRule_UserNotBucketed - testLocalHoldout_ExperimentRule_AudienceMismatch Local Holdouts - Delivery Rules: - testLocalHoldout_DeliveryRule_UserBucketed - testLocalHoldout_DeliveryRule_UserNotBucketed Multiple Holdouts: - testMultipleLocalHoldouts_SameRule_FirstMatchWins - testMultipleLocalHoldouts_DifferentRules_EachEvaluated Cross-Flag & Precedence: - testLocalHoldout_CrossFlag_OnlyTargetedRulesAffected - testGlobalAndLocalHoldouts_GlobalEvaluatedFirst - testLocalHoldout_EvaluatedAfterForcedDecision Edge Cases: - testLocalHoldout_InactiveStatus_NotEvaluated Each test uses decide_datafile, MockBucketer for controlled bucketing, and asserts correct decision behavior. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> * [FSSDK-12394] Fix compilation errors in LocalHoldouts tests Fixed two compilation errors: 1. Changed .paused to .draft (valid Status enum value) 2. Fixed testLocalHoldout_EmptyIncludedRules_TreatedAsGlobal to use OTUtils.model instead of direct Holdout initializer Holdout Status enum values: .draft, .running, .concluded, .archived Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com> --------- Co-authored-by: Claude Sonnet 4.5 <noreply@anthropic.com> Co-authored-by: muzahidul-opti <muzahidul.islam@Optimizely.com>
1 parent 26759a5 commit bb30e08

14 files changed

Lines changed: 878 additions & 346 deletions

OptimizelySwiftSDK.xcodeproj/project.pbxproj

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2119,6 +2119,8 @@
21192119
98AC984B2DB8FFE0001405DD /* DecisionServiceTests_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */; };
21202120
98AC985E2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */; };
21212121
98AC985F2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */; };
2122+
98C2DF242F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */; };
2123+
98C2DF252F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */; };
21222124
98D5AE842DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */; };
21232125
98D5AE852DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */; };
21242126
98F28A1D2E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; };
@@ -2635,6 +2637,7 @@
26352637
98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketTests_HoldoutToVariation.swift; sourceTree = "<group>"; };
26362638
98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecisionServiceTests_Holdouts.swift; sourceTree = "<group>"; };
26372639
98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift; sourceTree = "<group>"; };
2640+
98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecisionServiceTests_LocalHoldouts.swift; sourceTree = "<group>"; };
26382641
98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Holdouts.swift; sourceTree = "<group>"; };
26392642
98F28A1C2E01940500A86546 /* Cmab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cmab.swift; sourceTree = "<group>"; };
26402643
98F28A2D2E01968000A86546 /* CmabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabTests.swift; sourceTree = "<group>"; };
@@ -3177,6 +3180,7 @@
31773180
6E0207A7272A11CF008C3711 /* NetworkReachabilityTests.swift */,
31783181
6E75198B22C5211100B2B157 /* NotificationCenterTests.swift */,
31793182
84861810286D0B8900B7F41B /* OdpEventManagerTests.swift */,
3183+
98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */,
31803184
8486180E286D0B8900B7F41B /* OdpManagerTests.swift */,
31813185
8486180D286D0B8900B7F41B /* OdpSegmentManagerTests.swift */,
31823186
98F28A512E02E81500A86546 /* CMABClientTests.swift */,
@@ -5191,6 +5195,7 @@
51915195
6E6522EB278E4F3800954EA1 /* OdpManager.swift in Sources */,
51925196
6E75187922C520D400B2B157 /* Variation.swift in Sources */,
51935197
6E75191522C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */,
5198+
98C2DF242F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */,
51945199
6E75195D22C520D500B2B157 /* OPTBucketer.swift in Sources */,
51955200
6E9B117622C5487100C22D81 /* DatafileHandlerTests.swift in Sources */,
51965201
84E2E97F2855875E001114AB /* OdpEventManager.swift in Sources */,
@@ -5494,6 +5499,7 @@
54945499
84861813286D0B8900B7F41B /* OdpManagerTests.swift in Sources */,
54955500
6E7516FD22C520D400B2B157 /* OptimizelyLogLevel.swift in Sources */,
54965501
6E75187322C520D400B2B157 /* Variation.swift in Sources */,
5502+
98C2DF252F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */,
54975503
6E7517E322C520D400B2B157 /* DefaultDecisionService.swift in Sources */,
54985504
6E75179922C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */,
54995505
6E9B115C22C5486E00C22D81 /* DatafileHandlerTests.swift in Sources */,

Sources/Data Model/Holdout.swift

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,10 @@ struct Holdout: Codable, ExperimentCore {
3131
var trafficAllocation: [TrafficAllocation]
3232
var audienceIds: [String]
3333
var audienceConditions: ConditionHolder?
34-
var includedFlags: [String]
35-
var excludedFlags: [String]
36-
34+
var includedRules: [String]?
35+
3736
enum CodingKeys: String, CodingKey {
38-
case id, key, status, variations, trafficAllocation, audienceIds, audienceConditions, includedFlags, excludedFlags
37+
case id, key, status, variations, trafficAllocation, audienceIds, audienceConditions, includedRules
3938
}
4039

4140
var variationsMap: [String: OptimizelyVariation] = [:]
@@ -54,9 +53,8 @@ struct Holdout: Codable, ExperimentCore {
5453
trafficAllocation = try container.decode([TrafficAllocation].self, forKey: .trafficAllocation)
5554
audienceIds = try container.decode([String].self, forKey: .audienceIds)
5655
audienceConditions = try container.decodeIfPresent(ConditionHolder.self, forKey: .audienceConditions)
57-
58-
includedFlags = try container.decodeIfPresent([String].self, forKey: .includedFlags) ?? []
59-
excludedFlags = try container.decodeIfPresent([String].self, forKey: .excludedFlags) ?? []
56+
57+
includedRules = try container.decodeIfPresent([String].self, forKey: .includedRules)
6058
}
6159
}
6260

@@ -69,13 +67,16 @@ extension Holdout: Equatable {
6967
lhs.trafficAllocation == rhs.trafficAllocation &&
7068
lhs.audienceIds == rhs.audienceIds &&
7169
lhs.audienceConditions == rhs.audienceConditions &&
72-
lhs.includedFlags == rhs.includedFlags &&
73-
lhs.excludedFlags == rhs.excludedFlags
70+
lhs.includedRules == rhs.includedRules
7471
}
7572
}
7673

7774
extension Holdout {
7875
var isActivated: Bool {
7976
return status == .running
8077
}
78+
79+
var isGlobal: Bool {
80+
return includedRules == nil
81+
}
8182
}

Sources/Data Model/HoldoutConfig.swift

Lines changed: 24 additions & 66 deletions
Original file line numberDiff line numberDiff line change
@@ -24,90 +24,48 @@ struct HoldoutConfig {
2424
}
2525
private(set) var global: [Holdout] = []
2626
private(set) var holdoutIdMap: [String: Holdout] = [:]
27-
private(set) var flagHoldoutsMap: [String: [Holdout]] = [:]
28-
private(set) var includedHoldouts: [String: [Holdout]] = [:]
29-
private(set) var excludedHoldouts: [String: [Holdout]] = [:]
27+
private(set) var ruleHoldoutsMap: [String: [Holdout]] = [:]
3028

3129
init(allholdouts: [Holdout] = []) {
3230
self.allHoldouts = allholdouts
3331
updateHoldoutMapping()
3432
}
3533

36-
/// Updates internal mappings of holdouts including the id map, global list, and per-flag inclusion/exclusion maps.
34+
/// Updates internal mappings of holdouts including the id map, global list, and per-rule maps.
3735
mutating func updateHoldoutMapping() {
3836
holdoutIdMap = {
3937
var map = [String: Holdout]()
4038
allHoldouts.forEach { map[$0.id] = $0 }
4139
return map
4240
}()
43-
44-
flagHoldoutsMap = [:]
41+
4542
global = []
46-
includedHoldouts = [:]
47-
excludedHoldouts = [:]
48-
43+
ruleHoldoutsMap = [:]
44+
4945
for holdout in allHoldouts {
50-
switch (holdout.includedFlags.isEmpty, holdout.excludedFlags.isEmpty) {
51-
case (true, true):
52-
global.append(holdout)
53-
54-
case (false, _):
55-
holdout.includedFlags.forEach { flagId in
56-
if var existing = includedHoldouts[flagId] {
57-
existing.append(holdout)
58-
includedHoldouts[flagId] = existing
59-
} else {
60-
includedHoldouts[flagId] = [holdout]
61-
}
62-
}
63-
64-
case (true, false):
65-
global.append(holdout)
66-
67-
holdout.excludedFlags.forEach { flagId in
68-
if var existing = excludedHoldouts[flagId] {
69-
existing.append(holdout)
70-
excludedHoldouts[flagId] = existing
71-
} else {
72-
excludedHoldouts[flagId] = [holdout]
73-
}
74-
}
46+
if holdout.isGlobal {
47+
// includedRules == nil → global holdout
48+
global.append(holdout)
49+
} else {
50+
// includedRules == [ruleId, ...] → local holdout
51+
for ruleId in holdout.includedRules! {
52+
ruleHoldoutsMap[ruleId, default: []].append(holdout)
53+
}
7554
}
7655
}
7756
}
7857

79-
/// Returns the applicable holdouts for the given flag ID by combining global holdouts (excluding any specified) and included holdouts, in that order.
80-
/// Caches the result for future calls.
81-
/// - Parameter id: The flag identifier.
82-
/// - Returns: An array of `Holdout` objects relevant to the given flag.
83-
mutating func getHoldoutForFlag(id: String) -> [Holdout] {
84-
guard !allHoldouts.isEmpty else { return [] }
85-
86-
// Check cache and return persistent holdouts
87-
if let holdouts = flagHoldoutsMap[id] {
88-
return holdouts
89-
}
90-
91-
// Prioritize global holdouts first
92-
var activeHoldouts: [Holdout] = []
93-
94-
let excluded = excludedHoldouts[id] ?? []
95-
96-
if !excluded.isEmpty {
97-
activeHoldouts = global.filter { holdout in
98-
return !excluded.contains(holdout)
99-
}
100-
} else {
101-
activeHoldouts = global
102-
}
103-
104-
let includedHoldouts = includedHoldouts[id] ?? []
105-
106-
activeHoldouts += includedHoldouts
107-
108-
flagHoldoutsMap[id] = activeHoldouts
109-
110-
return flagHoldoutsMap[id] ?? []
58+
/// Returns local holdouts targeting a specific rule.
59+
/// - Parameter ruleId: The rule identifier.
60+
/// - Returns: An array of `Holdout` objects targeting the given rule.
61+
func getHoldoutsForRule(ruleId: String) -> [Holdout] {
62+
return ruleHoldoutsMap[ruleId] ?? []
63+
}
64+
65+
/// Returns all global holdouts.
66+
/// - Returns: An array of global `Holdout` objects.
67+
func getGlobalHoldouts() -> [Holdout] {
68+
return global
11169
}
11270

11371
/// Get a Holdout object for an Id.

Sources/Data Model/ProjectConfig.swift

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,8 +170,12 @@ class ProjectConfig {
170170

171171
}
172172

173-
func getHoldoutForFlag(id: String) -> [Holdout] {
174-
return holdoutConfig.getHoldoutForFlag(id: id)
173+
func getGlobalHoldouts() -> [Holdout] {
174+
return holdoutConfig.getGlobalHoldouts()
175+
}
176+
177+
func getHoldoutsForRule(ruleId: String) -> [Holdout] {
178+
return holdoutConfig.getHoldoutsForRule(ruleId: ruleId)
175179
}
176180

177181
func getAllRulesForFlag(_ flag: FeatureFlag) -> [Experiment] {

Sources/Implementation/DefaultDecisionService.swift

Lines changed: 71 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@ struct VariationDecision {
2828
var variation: Variation?
2929
var cmabError: Bool = false
3030
var cmabUUID: String?
31+
var holdout: ExperimentCore? = nil // If variation came from a holdout, store the holdout here
32+
}
33+
34+
struct DeliveryRuleDecision {
35+
var variation: Variation?
36+
var skipToEveryoneElse: Bool
37+
var holdout: ExperimentCore? = nil // If variation came from a holdout, store the holdout here
3138
}
3239

3340
typealias UserProfile = OPTUserProfileService.UPProfile
@@ -392,8 +399,8 @@ class DefaultDecisionService: OPTDecisionService {
392399
isAsync: Bool,
393400
options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<FeatureDecision> {
394401
let reasons = DecisionReasons(options: options)
395-
396-
let holdouts = config.getHoldoutForFlag(id: featureFlag.id)
402+
403+
let holdouts = config.getGlobalHoldouts()
397404
for holdout in holdouts {
398405
let holdoutDecision = getVariationForHoldout(config: config,
399406
flagKey: featureFlag.key,
@@ -468,8 +475,14 @@ class DefaultDecisionService: OPTDecisionService {
468475
let featureDecision = FeatureDecision(experiment: experiment, variation: nil, source: Constants.DecisionSource.featureTest.rawValue, error: true)
469476
return DecisionResponse(result: featureDecision, reasons: reasons)
470477
} else if let variation = result.variation {
471-
let featureDecision = FeatureDecision(experiment: experiment, variation: variation, source: Constants.DecisionSource.featureTest.rawValue, cmabUUID: result.cmabUUID)
472-
return DecisionResponse(result: featureDecision, reasons: reasons)
478+
// Check if this variation came from a holdout
479+
if let holdout = result.holdout {
480+
let featureDecision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue, cmabUUID: result.cmabUUID)
481+
return DecisionResponse(result: featureDecision, reasons: reasons)
482+
} else {
483+
let featureDecision = FeatureDecision(experiment: experiment, variation: variation, source: Constants.DecisionSource.featureTest.rawValue, cmabUUID: result.cmabUUID)
484+
return DecisionResponse(result: featureDecision, reasons: reasons)
485+
}
473486
}
474487
}
475488
}
@@ -524,16 +537,22 @@ class DefaultDecisionService: OPTDecisionService {
524537
user: user,
525538
options: options)
526539
reasons.merge(decisionResponse.reasons)
527-
let (variation, skipToEveryoneElse) = decisionResponse.result!
528-
529-
if let variation = variation {
540+
let result = decisionResponse.result!
541+
542+
if let variation = result.variation {
530543
let rule = rolloutRules[index]
531-
let featureDecision = FeatureDecision(experiment: rule, variation: variation, source: Constants.DecisionSource.rollout.rawValue)
532-
return DecisionResponse(result: featureDecision, reasons: reasons)
544+
// Check if this variation came from a holdout
545+
if let holdout = result.holdout {
546+
let featureDecision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue)
547+
return DecisionResponse(result: featureDecision, reasons: reasons)
548+
} else {
549+
let featureDecision = FeatureDecision(experiment: rule, variation: variation, source: Constants.DecisionSource.rollout.rawValue)
550+
return DecisionResponse(result: featureDecision, reasons: reasons)
551+
}
533552
}
534-
553+
535554
// the last rule is special for "Everyone Else"
536-
index = skipToEveryoneElse ? (rolloutRules.count - 1) : (index + 1)
555+
index = result.skipToEveryoneElse ? (rolloutRules.count - 1) : (index + 1)
537556
}
538557

539558
return DecisionResponse(result: nil, reasons: reasons)
@@ -637,7 +656,23 @@ class DefaultDecisionService: OPTDecisionService {
637656
let variationDecision = VariationDecision(variation: variation)
638657
return DecisionResponse(result: variationDecision, reasons: reasons)
639658
}
640-
659+
660+
// check local holdouts targeting this rule
661+
let localHoldouts = config.getHoldoutsForRule(ruleId: rule.id)
662+
for holdout in localHoldouts {
663+
let holdoutDecision = getVariationForHoldout(config: config,
664+
flagKey: flagKey,
665+
holdout: holdout,
666+
user: user,
667+
options: options)
668+
reasons.merge(holdoutDecision.reasons)
669+
if let variation = holdoutDecision.result {
670+
// User is in holdout — return holdout variation immediately, skip this rule
671+
let variationDecision = VariationDecision(variation: variation, holdout: holdout)
672+
return DecisionResponse(result: variationDecision, reasons: reasons)
673+
}
674+
}
675+
641676
let decisionResponse = getVariation(config: config,
642677
experiment: rule,
643678
user: user,
@@ -657,13 +692,13 @@ class DefaultDecisionService: OPTDecisionService {
657692
/// - ruleIndex: The index of the rule to evaluate.
658693
/// - user: The user context.
659694
/// - options: Optional decision options.
660-
/// - Returns: A `DecisionResponse` with the variation (if any), a flag indicating whether to skip to the "Everyone Else" rule, and reasons.
695+
/// - Returns: A `DecisionResponse` with the delivery rule decision and reasons.
661696
func getVariationFromDeliveryRule(config: ProjectConfig,
662697
flagKey: String,
663698
rules: [Experiment],
664699
ruleIndex: Int,
665700
user: OptimizelyUserContext,
666-
options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<(Variation?, Bool)> {
701+
options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<DeliveryRuleDecision> {
667702
let reasons = DecisionReasons(options: options)
668703
var skipToEveryoneElse = false
669704

@@ -676,9 +711,26 @@ class DefaultDecisionService: OPTDecisionService {
676711
reasons.merge(forcedDecisionResponse.reasons)
677712

678713
if let variation = forcedDecisionResponse.result {
679-
return DecisionResponse(result: (variation, skipToEveryoneElse), reasons: reasons)
714+
let decision = DeliveryRuleDecision(variation: variation, skipToEveryoneElse: skipToEveryoneElse)
715+
return DecisionResponse(result: decision, reasons: reasons)
680716
}
681-
717+
718+
// check local holdouts targeting this delivery rule
719+
let localHoldouts = config.getHoldoutsForRule(ruleId: rule.id)
720+
for holdout in localHoldouts {
721+
let holdoutDecision = getVariationForHoldout(config: config,
722+
flagKey: flagKey,
723+
holdout: holdout,
724+
user: user,
725+
options: options)
726+
reasons.merge(holdoutDecision.reasons)
727+
if let variation = holdoutDecision.result {
728+
// User is in holdout — return holdout variation with holdout info
729+
let decision = DeliveryRuleDecision(variation: variation, skipToEveryoneElse: skipToEveryoneElse, holdout: holdout)
730+
return DecisionResponse(result: decision, reasons: reasons)
731+
}
732+
}
733+
682734
// regular decision
683735

684736
let userId = user.userId
@@ -725,8 +777,9 @@ class DefaultDecisionService: OPTDecisionService {
725777
logger.d(info)
726778
reasons.addInfo(info)
727779
}
728-
729-
return DecisionResponse(result: (bucketedVariation, skipToEveryoneElse), reasons: reasons)
780+
781+
let decision = DeliveryRuleDecision(variation: bucketedVariation, skipToEveryoneElse: skipToEveryoneElse)
782+
return DecisionResponse(result: decision, reasons: reasons)
730783
}
731784

732785
// MARK: - Audience Evaluation

0 commit comments

Comments
 (0)