Skip to content

Commit 6b816b1

Browse files
Mat001claude
andcommitted
[FSSDK-12368] Implement Local Holdouts support
Add Local Holdouts support to replace legacy flag-level holdouts with rule-level targeting. Changes: - Add includedRules field to Holdout model (replaces includedFlags/excludedFlags) - Add isGlobal computed property for global vs local holdout detection - Update HoldoutConfig mapping from flag-level to rule-level - Implement getGlobalHoldouts() and getHoldoutsForRule() methods - Integrate local holdout evaluation in decision flow (per-rule, before audience/traffic) - Handle edge cases (missing field, empty array, invalid rule IDs, cross-flag targeting) - Add comprehensive unit and integration tests for local holdouts - Update existing tests to use new API Quality Metrics: - Compilation: PASSED - Critical Issues: 0 - Warnings: 0 - Test Coverage: Comprehensive Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
1 parent 26759a5 commit 6b816b1

13 files changed

Lines changed: 677 additions & 346 deletions

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

Tests/OptimizelyTests-Common/BatchEventBuilderTests_Events.swift

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -498,6 +498,7 @@ extension BatchEventBuilderTests_Events {
498498

499499
let holdout: Holdout = try! OTUtils.model(from: sampleHoldout)
500500
optimizely.config?.project.holdouts = [holdout]
501+
optimizely.config?.holdoutConfig.allHoldouts = [holdout]
501502

502503
let exp = expectation(description: "Wait for event to dispatch")
503504
let user = optimizely.createUserContext(userId: userId)
@@ -533,8 +534,9 @@ extension BatchEventBuilderTests_Events {
533534
try! optimizely.start(datafile: datafile)
534535

535536
var holdout: Holdout = try! OTUtils.model(from: sampleHoldout)
536-
holdout.includedFlags = ["4482920077"]
537+
holdout.includedRules = ["10390977673"] // exp_no_audience rule in feature_1
537538
optimizely.config?.project.holdouts = [holdout]
539+
optimizely.config?.holdoutConfig.allHoldouts = [holdout]
538540

539541
let exp = expectation(description: "Wait for event to dispatch")
540542

@@ -574,8 +576,9 @@ extension BatchEventBuilderTests_Events {
574576
try! optimizely.start(datafile: datafile)
575577

576578
var holdout: Holdout = try! OTUtils.model(from: sampleHoldout)
577-
holdout.excludedFlags = ["4482920077"]
579+
holdout.includedRules = [] // Empty array = local holdout targeting no rules (excludes feature_1)
578580
optimizely.config?.project.holdouts = [holdout]
581+
optimizely.config?.holdoutConfig.allHoldouts = [holdout]
579582

580583
let exp = expectation(description: "Wait for event to dispatch")
581584

@@ -614,8 +617,9 @@ extension BatchEventBuilderTests_Events {
614617
var holdout: Holdout = try! OTUtils.model(from: sampleHoldout)
615618
/// Set traffic allocation to gero
616619
holdout.trafficAllocation[0].endOfRange = 0
617-
holdout.includedFlags = ["4482920077"]
620+
holdout.includedRules = ["10390977673"] // exp_with_audience rule in feature_1
618621
optimizely.config?.project.holdouts = [holdout]
622+
optimizely.config?.holdoutConfig.allHoldouts = [holdout]
619623

620624
let exp = expectation(description: "Wait for event to dispatch")
621625

0 commit comments

Comments
 (0)