-
Notifications
You must be signed in to change notification settings - Fork 33
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
implement experiment manager (#1066)
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
1 parent
7a7bb6d
commit 01d7534
Showing
7 changed files
with
578 additions
and
1 deletion.
There are no files selected for viewing
107 changes: 107 additions & 0 deletions
107
Sources/BrowserServicesKit/PrivacyConfig/ExperimentCohortsManager.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
} | ||
} |
58 changes: 58 additions & 0 deletions
58
Sources/BrowserServicesKit/PrivacyConfig/ExperimentsDataStore.swift
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 {} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.