Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions OptimizelySwiftSDK.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -2119,6 +2119,8 @@
98AC984B2DB8FFE0001405DD /* DecisionServiceTests_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */; };
98AC985E2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */; };
98AC985F2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */; };
98C2DF242F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */; };
98C2DF252F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */; };
98D5AE842DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */; };
98D5AE852DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */; };
98F28A1D2E01940500A86546 /* Cmab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98F28A1C2E01940500A86546 /* Cmab.swift */; };
Expand Down Expand Up @@ -2635,6 +2637,7 @@
98AC98452DB7B762001405DD /* BucketTests_HoldoutToVariation.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BucketTests_HoldoutToVariation.swift; sourceTree = "<group>"; };
98AC98482DB8FC29001405DD /* DecisionServiceTests_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecisionServiceTests_Holdouts.swift; sourceTree = "<group>"; };
98AC985D2DBA6721001405DD /* OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_With_Holdouts_Reasons.swift; sourceTree = "<group>"; };
98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecisionServiceTests_LocalHoldouts.swift; sourceTree = "<group>"; };
98D5AE832DBB91C0000D5844 /* OptimizelyUserContextTests_Decide_Holdouts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OptimizelyUserContextTests_Decide_Holdouts.swift; sourceTree = "<group>"; };
98F28A1C2E01940500A86546 /* Cmab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cmab.swift; sourceTree = "<group>"; };
98F28A2D2E01968000A86546 /* CmabTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmabTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -3177,6 +3180,7 @@
6E0207A7272A11CF008C3711 /* NetworkReachabilityTests.swift */,
6E75198B22C5211100B2B157 /* NotificationCenterTests.swift */,
84861810286D0B8900B7F41B /* OdpEventManagerTests.swift */,
98C2DF232F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift */,
8486180E286D0B8900B7F41B /* OdpManagerTests.swift */,
8486180D286D0B8900B7F41B /* OdpSegmentManagerTests.swift */,
98F28A512E02E81500A86546 /* CMABClientTests.swift */,
Expand Down Expand Up @@ -5191,6 +5195,7 @@
6E6522EB278E4F3800954EA1 /* OdpManager.swift in Sources */,
6E75187922C520D400B2B157 /* Variation.swift in Sources */,
6E75191522C520D500B2B157 /* BackgroundingCallbacks.swift in Sources */,
98C2DF242F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */,
6E75195D22C520D500B2B157 /* OPTBucketer.swift in Sources */,
6E9B117622C5487100C22D81 /* DatafileHandlerTests.swift in Sources */,
84E2E97F2855875E001114AB /* OdpEventManager.swift in Sources */,
Expand Down Expand Up @@ -5494,6 +5499,7 @@
84861813286D0B8900B7F41B /* OdpManagerTests.swift in Sources */,
6E7516FD22C520D400B2B157 /* OptimizelyLogLevel.swift in Sources */,
6E75187322C520D400B2B157 /* Variation.swift in Sources */,
98C2DF252F900669003F2443 /* DecisionServiceTests_LocalHoldouts.swift in Sources */,
6E7517E322C520D400B2B157 /* DefaultDecisionService.swift in Sources */,
6E75179922C520D400B2B157 /* DataStoreQueueStackImpl+Extension.swift in Sources */,
6E9B115C22C5486E00C22D81 /* DatafileHandlerTests.swift in Sources */,
Expand Down
19 changes: 10 additions & 9 deletions Sources/Data Model/Holdout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,11 +31,10 @@ struct Holdout: Codable, ExperimentCore {
var trafficAllocation: [TrafficAllocation]
var audienceIds: [String]
var audienceConditions: ConditionHolder?
var includedFlags: [String]
var excludedFlags: [String]

var includedRules: [String]?

enum CodingKeys: String, CodingKey {
case id, key, status, variations, trafficAllocation, audienceIds, audienceConditions, includedFlags, excludedFlags
case id, key, status, variations, trafficAllocation, audienceIds, audienceConditions, includedRules
}

var variationsMap: [String: OptimizelyVariation] = [:]
Expand All @@ -54,9 +53,8 @@ struct Holdout: Codable, ExperimentCore {
trafficAllocation = try container.decode([TrafficAllocation].self, forKey: .trafficAllocation)
audienceIds = try container.decode([String].self, forKey: .audienceIds)
audienceConditions = try container.decodeIfPresent(ConditionHolder.self, forKey: .audienceConditions)

includedFlags = try container.decodeIfPresent([String].self, forKey: .includedFlags) ?? []
excludedFlags = try container.decodeIfPresent([String].self, forKey: .excludedFlags) ?? []

includedRules = try container.decodeIfPresent([String].self, forKey: .includedRules)
}
}

Expand All @@ -69,13 +67,16 @@ extension Holdout: Equatable {
lhs.trafficAllocation == rhs.trafficAllocation &&
lhs.audienceIds == rhs.audienceIds &&
lhs.audienceConditions == rhs.audienceConditions &&
lhs.includedFlags == rhs.includedFlags &&
lhs.excludedFlags == rhs.excludedFlags
lhs.includedRules == rhs.includedRules
}
}

extension Holdout {
var isActivated: Bool {
return status == .running
}

var isGlobal: Bool {
return includedRules == nil
}
}
90 changes: 24 additions & 66 deletions Sources/Data Model/HoldoutConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -24,90 +24,48 @@ struct HoldoutConfig {
}
private(set) var global: [Holdout] = []
private(set) var holdoutIdMap: [String: Holdout] = [:]
private(set) var flagHoldoutsMap: [String: [Holdout]] = [:]
private(set) var includedHoldouts: [String: [Holdout]] = [:]
private(set) var excludedHoldouts: [String: [Holdout]] = [:]
private(set) var ruleHoldoutsMap: [String: [Holdout]] = [:]

init(allholdouts: [Holdout] = []) {
self.allHoldouts = allholdouts
updateHoldoutMapping()
}

/// Updates internal mappings of holdouts including the id map, global list, and per-flag inclusion/exclusion maps.
/// Updates internal mappings of holdouts including the id map, global list, and per-rule maps.
mutating func updateHoldoutMapping() {
holdoutIdMap = {
var map = [String: Holdout]()
allHoldouts.forEach { map[$0.id] = $0 }
return map
}()

flagHoldoutsMap = [:]

global = []
includedHoldouts = [:]
excludedHoldouts = [:]

ruleHoldoutsMap = [:]

for holdout in allHoldouts {
switch (holdout.includedFlags.isEmpty, holdout.excludedFlags.isEmpty) {
case (true, true):
global.append(holdout)

case (false, _):
holdout.includedFlags.forEach { flagId in
if var existing = includedHoldouts[flagId] {
existing.append(holdout)
includedHoldouts[flagId] = existing
} else {
includedHoldouts[flagId] = [holdout]
}
}

case (true, false):
global.append(holdout)

holdout.excludedFlags.forEach { flagId in
if var existing = excludedHoldouts[flagId] {
existing.append(holdout)
excludedHoldouts[flagId] = existing
} else {
excludedHoldouts[flagId] = [holdout]
}
}
if holdout.isGlobal {
// includedRules == nil → global holdout
global.append(holdout)
} else {
// includedRules == [ruleId, ...] → local holdout
for ruleId in holdout.includedRules! {
ruleHoldoutsMap[ruleId, default: []].append(holdout)
}
}
}
}

/// Returns the applicable holdouts for the given flag ID by combining global holdouts (excluding any specified) and included holdouts, in that order.
/// Caches the result for future calls.
/// - Parameter id: The flag identifier.
/// - Returns: An array of `Holdout` objects relevant to the given flag.
mutating func getHoldoutForFlag(id: String) -> [Holdout] {
guard !allHoldouts.isEmpty else { return [] }

// Check cache and return persistent holdouts
if let holdouts = flagHoldoutsMap[id] {
return holdouts
}

// Prioritize global holdouts first
var activeHoldouts: [Holdout] = []

let excluded = excludedHoldouts[id] ?? []

if !excluded.isEmpty {
activeHoldouts = global.filter { holdout in
return !excluded.contains(holdout)
}
} else {
activeHoldouts = global
}

let includedHoldouts = includedHoldouts[id] ?? []

activeHoldouts += includedHoldouts

flagHoldoutsMap[id] = activeHoldouts

return flagHoldoutsMap[id] ?? []
/// Returns local holdouts targeting a specific rule.
/// - Parameter ruleId: The rule identifier.
/// - Returns: An array of `Holdout` objects targeting the given rule.
func getHoldoutsForRule(ruleId: String) -> [Holdout] {
return ruleHoldoutsMap[ruleId] ?? []
}

/// Returns all global holdouts.
/// - Returns: An array of global `Holdout` objects.
func getGlobalHoldouts() -> [Holdout] {
return global
}

/// Get a Holdout object for an Id.
Expand Down
8 changes: 6 additions & 2 deletions Sources/Data Model/ProjectConfig.swift
Original file line number Diff line number Diff line change
Expand Up @@ -170,8 +170,12 @@ class ProjectConfig {

}

func getHoldoutForFlag(id: String) -> [Holdout] {
return holdoutConfig.getHoldoutForFlag(id: id)
func getGlobalHoldouts() -> [Holdout] {
return holdoutConfig.getGlobalHoldouts()
}

func getHoldoutsForRule(ruleId: String) -> [Holdout] {
return holdoutConfig.getHoldoutsForRule(ruleId: ruleId)
}

func getAllRulesForFlag(_ flag: FeatureFlag) -> [Experiment] {
Expand Down
89 changes: 71 additions & 18 deletions Sources/Implementation/DefaultDecisionService.swift
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,13 @@ struct VariationDecision {
var variation: Variation?
var cmabError: Bool = false
var cmabUUID: String?
var holdout: ExperimentCore? = nil // If variation came from a holdout, store the holdout here
}

struct DeliveryRuleDecision {
var variation: Variation?
var skipToEveryoneElse: Bool
var holdout: ExperimentCore? = nil // If variation came from a holdout, store the holdout here
}

typealias UserProfile = OPTUserProfileService.UPProfile
Expand Down Expand Up @@ -392,8 +399,8 @@ class DefaultDecisionService: OPTDecisionService {
isAsync: Bool,
options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<FeatureDecision> {
let reasons = DecisionReasons(options: options)
let holdouts = config.getHoldoutForFlag(id: featureFlag.id)

let holdouts = config.getGlobalHoldouts()
for holdout in holdouts {
let holdoutDecision = getVariationForHoldout(config: config,
flagKey: featureFlag.key,
Expand Down Expand Up @@ -468,8 +475,14 @@ class DefaultDecisionService: OPTDecisionService {
let featureDecision = FeatureDecision(experiment: experiment, variation: nil, source: Constants.DecisionSource.featureTest.rawValue, error: true)
return DecisionResponse(result: featureDecision, reasons: reasons)
} else if let variation = result.variation {
let featureDecision = FeatureDecision(experiment: experiment, variation: variation, source: Constants.DecisionSource.featureTest.rawValue, cmabUUID: result.cmabUUID)
return DecisionResponse(result: featureDecision, reasons: reasons)
// Check if this variation came from a holdout
if let holdout = result.holdout {
let featureDecision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue, cmabUUID: result.cmabUUID)
return DecisionResponse(result: featureDecision, reasons: reasons)
} else {
let featureDecision = FeatureDecision(experiment: experiment, variation: variation, source: Constants.DecisionSource.featureTest.rawValue, cmabUUID: result.cmabUUID)
return DecisionResponse(result: featureDecision, reasons: reasons)
}
}
}
}
Expand Down Expand Up @@ -524,16 +537,22 @@ class DefaultDecisionService: OPTDecisionService {
user: user,
options: options)
reasons.merge(decisionResponse.reasons)
let (variation, skipToEveryoneElse) = decisionResponse.result!
if let variation = variation {
let result = decisionResponse.result!

if let variation = result.variation {
let rule = rolloutRules[index]
let featureDecision = FeatureDecision(experiment: rule, variation: variation, source: Constants.DecisionSource.rollout.rawValue)
return DecisionResponse(result: featureDecision, reasons: reasons)
// Check if this variation came from a holdout
if let holdout = result.holdout {
let featureDecision = FeatureDecision(experiment: holdout, variation: variation, source: Constants.DecisionSource.holdout.rawValue)
return DecisionResponse(result: featureDecision, reasons: reasons)
} else {
let featureDecision = FeatureDecision(experiment: rule, variation: variation, source: Constants.DecisionSource.rollout.rawValue)
return DecisionResponse(result: featureDecision, reasons: reasons)
}
}

// the last rule is special for "Everyone Else"
index = skipToEveryoneElse ? (rolloutRules.count - 1) : (index + 1)
index = result.skipToEveryoneElse ? (rolloutRules.count - 1) : (index + 1)
}

return DecisionResponse(result: nil, reasons: reasons)
Expand Down Expand Up @@ -637,7 +656,23 @@ class DefaultDecisionService: OPTDecisionService {
let variationDecision = VariationDecision(variation: variation)
return DecisionResponse(result: variationDecision, reasons: reasons)
}


// check local holdouts targeting this rule
let localHoldouts = config.getHoldoutsForRule(ruleId: rule.id)
for holdout in localHoldouts {
let holdoutDecision = getVariationForHoldout(config: config,
flagKey: flagKey,
holdout: holdout,
user: user,
options: options)
reasons.merge(holdoutDecision.reasons)
if let variation = holdoutDecision.result {
// User is in holdout — return holdout variation immediately, skip this rule
let variationDecision = VariationDecision(variation: variation, holdout: holdout)
return DecisionResponse(result: variationDecision, reasons: reasons)
}
}

let decisionResponse = getVariation(config: config,
experiment: rule,
user: user,
Expand All @@ -657,13 +692,13 @@ class DefaultDecisionService: OPTDecisionService {
/// - ruleIndex: The index of the rule to evaluate.
/// - user: The user context.
/// - options: Optional decision options.
/// - Returns: A `DecisionResponse` with the variation (if any), a flag indicating whether to skip to the "Everyone Else" rule, and reasons.
/// - Returns: A `DecisionResponse` with the delivery rule decision and reasons.
func getVariationFromDeliveryRule(config: ProjectConfig,
flagKey: String,
rules: [Experiment],
ruleIndex: Int,
user: OptimizelyUserContext,
options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<(Variation?, Bool)> {
options: [OptimizelyDecideOption]? = nil) -> DecisionResponse<DeliveryRuleDecision> {
let reasons = DecisionReasons(options: options)
var skipToEveryoneElse = false

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

if let variation = forcedDecisionResponse.result {
return DecisionResponse(result: (variation, skipToEveryoneElse), reasons: reasons)
let decision = DeliveryRuleDecision(variation: variation, skipToEveryoneElse: skipToEveryoneElse)
return DecisionResponse(result: decision, reasons: reasons)
}


// check local holdouts targeting this delivery rule
let localHoldouts = config.getHoldoutsForRule(ruleId: rule.id)
for holdout in localHoldouts {
let holdoutDecision = getVariationForHoldout(config: config,
flagKey: flagKey,
holdout: holdout,
user: user,
options: options)
reasons.merge(holdoutDecision.reasons)
if let variation = holdoutDecision.result {
// User is in holdout — return holdout variation with holdout info
let decision = DeliveryRuleDecision(variation: variation, skipToEveryoneElse: skipToEveryoneElse, holdout: holdout)
return DecisionResponse(result: decision, reasons: reasons)
}
}

// regular decision

let userId = user.userId
Expand Down Expand Up @@ -725,8 +777,9 @@ class DefaultDecisionService: OPTDecisionService {
logger.d(info)
reasons.addInfo(info)
}

return DecisionResponse(result: (bucketedVariation, skipToEveryoneElse), reasons: reasons)

let decision = DeliveryRuleDecision(variation: bucketedVariation, skipToEveryoneElse: skipToEveryoneElse)
return DecisionResponse(result: decision, reasons: reasons)
}

// MARK: - Audience Evaluation
Expand Down
Loading
Loading