Skip to content

Commit

Permalink
implement experiment manager (#1066)
Browse files Browse the repository at this point in the history
Task/Issue URL:
https://app.asana.com/0/1204186595873227/1208687542689224/f
iOS PR:  NA
macOS PR:  NA
What kind of version bump will this require?: NA

**Optional**:

Tech Design URL:
https://app.asana.com/0/481882893211075/1208680035673275/f
CC:

**Description**: Implements the ExperimentCohortManager according to the
TechDesign (it is not actually used yet)
  • Loading branch information
SabrinaTardio authored and mgurgel committed Nov 18, 2024
1 parent 7a7bb6d commit 01d7534
Show file tree
Hide file tree
Showing 7 changed files with 578 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
//
// 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

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

typealias CohortID = String
typealias SubfeatureID = String

struct ExperimentData: Codable, Equatable {
let cohort: String
let enrollmentDate: Date
}

typealias Experiments = [String: ExperimentData]

protocol ExperimentCohortsManaging {
/// Retrieves the cohort ID associated with the specified subfeature.
/// - Parameter subfeatureID: The name of the experiment subfeature for which the cohort ID is needed.
/// - Returns: The cohort ID as a `String` if one exists; otherwise, returns `nil`.
func cohort(for subfeatureID: SubfeatureID) -> CohortID?

/// Retrieves the enrollment date for the specified subfeature.
/// - Parameter subfeatureID: The name of the experiment subfeature for which the enrollment date is needed.
/// - Returns: The `Date` of enrollment if one exists; otherwise, returns `nil`.
func enrollmentDate(for subfeatureID: SubfeatureID) -> Date?

/// Assigns a cohort to the given subfeature based on defined weights and saves it to UserDefaults.
/// - Parameter subfeature: The ExperimentSubfeature to which a cohort needs to be assigned to.
/// - Returns: The name of the assigned cohort, or `nil` if no cohort could be assigned.
func assignCohort(to subfeature: ExperimentSubfeature) -> CohortID?

/// Removes the assigned cohort data for the specified subfeature.
/// - Parameter subfeatureID: The name of the experiment subfeature for which the cohort data should be removed.
func removeCohort(from subfeatureID: SubfeatureID)
}

final class ExperimentCohortsManager: ExperimentCohortsManaging {

private var store: ExperimentsDataStoring
private let randomizer: (Range<Double>) -> Double

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

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

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

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)
return cohort.name
}
}
return nil
}

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) {
var experiments = store.experiments ?? Experiments()
let experimentData = ExperimentData(cohort: cohort, enrollmentDate: Date())
experiments[experimentID] = experimentData
store.experiments = experiments
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
//
// ExperimentsDataStore.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

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

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

struct ExperimentsDataStore: ExperimentsDataStoring {

private enum Constants {
static let experimentsDataKey = "ExperimentsData"
}
private let localDataStoring: LocalDataStoring
private let decoder = JSONDecoder()
private let encoder = JSONEncoder()

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

var experiments: Experiments? {
get {
guard let savedData = localDataStoring.data(forKey: Constants.experimentsDataKey) else { return nil }
return try? decoder.decode(Experiments.self, from: savedData)
}
set {
if let encodedData = try? encoder.encode(newValue) {
localDataStoring.set(encodedData, forKey: Constants.experimentsDataKey)
}
}
}
}

extension UserDefaults: LocalDataStoring {}
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,20 @@ public struct PrivacyConfigurationData {
static public let enabled = "enabled"
}

public struct Cohort {
public let name: String
public let weight: Int

public init?(json: [String: Any]) {
guard let name = json["name"] as? String,
let weight = json["weight"] as? Int else {
return nil
}

self.name = name
self.weight = weight
}
}
public let features: [FeatureName: PrivacyFeature]
public let trackerAllowlist: TrackerAllowlist
public let unprotectedTemporary: [ExceptionEntry]
Expand Down Expand Up @@ -121,6 +135,7 @@ public struct PrivacyConfigurationData {
case state
case minSupportedVersion
case rollout
case cohorts
}

public struct Rollout: Hashable {
Expand Down Expand Up @@ -157,6 +172,7 @@ public struct PrivacyConfigurationData {
public let state: FeatureState
public let minSupportedVersion: FeatureSupportedVersion?
public let rollout: Rollout?
public let cohorts: [Cohort]?

public init?(json: [String: Any]) {
guard let state = json[CodingKeys.state.rawValue] as? String else {
Expand All @@ -171,6 +187,13 @@ public struct PrivacyConfigurationData {
} else {
self.rollout = nil
}

if let cohortData = json[CodingKeys.cohorts.rawValue] as? [[String: Any]] {
let parsedCohorts = cohortData.compactMap { Cohort(json: $0) }
cohorts = parsedCohorts.isEmpty ? nil : parsedCohorts
} else {
cohorts = nil
}
}
}

Expand Down
Loading

0 comments on commit 01d7534

Please sign in to comment.