Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

experiment framework #1100

Merged
merged 30 commits into from
Nov 29, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
30 commits
Select commit Hold shift + click to select a range
216a00e
implement experiment manager
SabrinaTardio Nov 8, 2024
d8b385c
Merge branch 'main' into sabrina/add-experiment-manager
SabrinaTardio Nov 8, 2024
914a5ed
fix lint issues
SabrinaTardio Nov 8, 2024
93247a3
fix linting issue
SabrinaTardio Nov 8, 2024
5d530f2
wrap UserDefaults
SabrinaTardio Nov 8, 2024
2dcef1e
fix linting
SabrinaTardio Nov 8, 2024
5aec88d
refactor
SabrinaTardio Nov 8, 2024
b188d67
fix linting
SabrinaTardio Nov 8, 2024
1054923
address some comments
SabrinaTardio Nov 8, 2024
c05aa96
minor refactor
SabrinaTardio Nov 8, 2024
8387472
use Constants enum
SabrinaTardio Nov 8, 2024
e862ad8
add Targets and configurations
SabrinaTardio Nov 11, 2024
6090a15
fix linting issues
SabrinaTardio Nov 11, 2024
14f92e9
initial implementation
SabrinaTardio Nov 12, 2024
8bda08c
add tests
SabrinaTardio Nov 14, 2024
9a19db2
Merge branch 'main' into sabrina/add-experiment-logic
SabrinaTardio Nov 14, 2024
56c54a7
refactor
SabrinaTardio Nov 15, 2024
86b7c97
getAllActiveExperiments tests
SabrinaTardio Nov 15, 2024
2595fcf
sort linting issues
SabrinaTardio Nov 15, 2024
86d6f7b
fix linting
SabrinaTardio Nov 15, 2024
95c4470
don't break the API
SabrinaTardio Nov 15, 2024
c30f0b4
Merge branch 'main' into sabrina/add-experiment-logic
SabrinaTardio Nov 15, 2024
69dc2fd
implement framework
SabrinaTardio Nov 27, 2024
cfe3b13
clean up
SabrinaTardio Nov 27, 2024
1e426e2
Merge branch 'main' into sabrina/experiment-framework
SabrinaTardio Nov 27, 2024
26fa2ed
fix linting
SabrinaTardio Nov 28, 2024
ee2f891
have only one interface method
SabrinaTardio Nov 28, 2024
b3224fe
fix lint
SabrinaTardio Nov 28, 2024
b057431
address comments
SabrinaTardio Nov 28, 2024
84643c1
tidy up typealias
SabrinaTardio Nov 29, 2024
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
//
// ExperimentCohortsManager.swift
//
// Copyright © 2024 DuckDuckGo. All rights reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//

import Foundation

public typealias CohortID = String
public typealias SubfeatureID = String
public typealias ParentFeatureID = String
public typealias Experiments = [String: ExperimentData]

public struct ExperimentSubfeature {
let parentID: ParentFeatureID
let subfeatureID: SubfeatureID
let cohorts: [PrivacyConfigurationData.Cohort]
}

public struct ExperimentData: Codable, Equatable {
public let parentID: ParentFeatureID
public let cohortID: CohortID
public let enrollmentDate: Date
}

public protocol ExperimentCohortsManaging {
/// Retrieves all the experiments a user is enrolled in
var experiments: Experiments? { get }

/// Resolves the cohort for a given experiment subfeature.
///
/// This method determines whether the user is currently assigned to a valid cohort
/// for the specified experiment. If the assigned cohort is valid (i.e., it matches
/// one of the experiment's defined cohorts), the method returns the assigned cohort.
/// Otherwise, the invalid cohort is removed, and a new cohort is assigned if
/// `allowCohortReassignment` is `true`.
///
/// - Parameters:
/// - experiment: The `ExperimentSubfeature` representing the experiment and its associated cohorts.
/// - allowCohortReassignment: A Boolean value indicating whether cohort assignment is allowed
/// if the user is not already assigned to a valid cohort.
///
/// - Returns: The valid `CohortID` assigned to the user for the experiment, or `nil`
/// if no valid cohort exists and `allowCohortReassignment` is `false`.
///
/// - Behavior:
/// 1. Retrieves the currently assigned cohort for the experiment using the `subfeatureID`.
/// 2. Validates if the assigned cohort exists within the experiment's cohort list:
/// - If valid, the assigned cohort is returned.
/// - If invalid, the cohort is removed from storage.
/// 3. If cohort assignment is enabled (`allowCohortReassignment` is `true`), a new cohort
/// is assigned based on the experiment's cohort weights and saved in storage.
/// - Cohort assignment is probabilistic, determined by the cohort weights.
///
func resolveCohort(for experiment: ExperimentSubfeature, allowCohortReassignment: Bool) -> CohortID?
}

public class ExperimentCohortsManager: ExperimentCohortsManaging {

private var store: ExperimentsDataStoring
private let randomizer: (Range<Double>) -> Double
private let queue = DispatchQueue(label: "com.ExperimentCohortsManager.queue")

public var experiments: Experiments? {
get {
queue.sync {
store.experiments
}
}
}

public init(store: ExperimentsDataStoring = ExperimentsDataStore(), randomizer: @escaping (Range<Double>) -> Double = Double.random(in:)) {
self.store = store
self.randomizer = randomizer
}

public func resolveCohort(for experiment: ExperimentSubfeature, allowCohortReassignment: Bool) -> CohortID? {
queue.sync {
let assignedCohort = cohort(for: experiment.subfeatureID)
if experiment.cohorts.contains(where: { $0.name == assignedCohort }) {
return assignedCohort
}
removeCohort(from: experiment.subfeatureID)
return allowCohortReassignment ? assignCohort(to: experiment) : nil
}
}
}

// MARK: Helper functions
extension ExperimentCohortsManager {

private func assignCohort(to subfeature: ExperimentSubfeature) -> CohortID? {
let cohorts = subfeature.cohorts
let totalWeight = cohorts.map(\.weight).reduce(0, +)
guard totalWeight > 0 else { return nil }

let randomValue = randomizer(0..<Double(totalWeight))
var cumulativeWeight = 0.0

for cohort in cohorts {
cumulativeWeight += Double(cohort.weight)
if randomValue < cumulativeWeight {
saveCohort(cohort.name, in: subfeature.subfeatureID, parentID: subfeature.parentID)
return cohort.name
}
}
return nil
}

func cohort(for subfeatureID: SubfeatureID) -> CohortID? {
guard let experiments = store.experiments else { return nil }
return experiments[subfeatureID]?.cohortID
}

private func enrollmentDate(for subfeatureID: SubfeatureID) -> Date? {
guard let experiments = store.experiments else { return nil }
return experiments[subfeatureID]?.enrollmentDate
}

private func removeCohort(from subfeatureID: SubfeatureID) {
guard var experiments = store.experiments else { return }
experiments.removeValue(forKey: subfeatureID)
store.experiments = experiments
}

private func saveCohort(_ cohort: CohortID, in experimentID: SubfeatureID, parentID: ParentFeatureID) {
var experiments = store.experiments ?? Experiments()
let experimentData = ExperimentData(parentID: parentID, cohortID: cohort, enrollmentDate: Date())
experiments[experimentID] = experimentData
store.experiments = experiments
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,16 @@

import Foundation

protocol ExperimentsDataStoring {
public protocol ExperimentsDataStoring {
var experiments: Experiments? { get set }
}

protocol LocalDataStoring {
public protocol LocalDataStoring {
func data(forKey defaultName: String) -> Data?
func set(_ value: Any?, forKey defaultName: String)
}

struct ExperimentsDataStore: ExperimentsDataStoring {
public struct ExperimentsDataStore: ExperimentsDataStoring {

private enum Constants {
static let experimentsDataKey = "ExperimentsData"
Expand All @@ -36,13 +36,13 @@ struct ExperimentsDataStore: ExperimentsDataStoring {
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()

init(localDataStoring: LocalDataStoring = UserDefaults.standard) {
public init(localDataStoring: LocalDataStoring = UserDefaults.standard) {
self.localDataStoring = localDataStoring
encoder.dateEncodingStrategy = .secondsSince1970
decoder.dateDecodingStrategy = .secondsSince1970
}

var experiments: Experiments? {
public var experiments: Experiments? {
get {
guard let savedData = localDataStoring.data(forKey: Constants.experimentsDataKey) else { return nil }
return try? decoder.decode(Experiments.self, from: savedData)
Expand Down
Loading
Loading