diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj index 303a742e..e441df53 100644 --- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj +++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj @@ -88,6 +88,9 @@ Entity\Experiment.cs + + Entity\Cmab.cs + Entity\Holdout.cs diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj index 3e0a9ea5..c1150280 100644 --- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj +++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj @@ -90,6 +90,9 @@ Entity\Experiment.cs + + Entity\Cmab.cs + Entity\Holdout.cs diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj index ab55bbac..1490ba14 100644 --- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj +++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj @@ -26,6 +26,7 @@ + diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj index 12f9cb55..418f606d 100644 --- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj +++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj @@ -178,6 +178,9 @@ Entity\Experiment.cs + + Entity\Cmab.cs + Entity\Holdout.cs diff --git a/OptimizelySDK.Tests/ProjectConfigTest.cs b/OptimizelySDK.Tests/ProjectConfigTest.cs index 15e95926..52373b2d 100644 --- a/OptimizelySDK.Tests/ProjectConfigTest.cs +++ b/OptimizelySDK.Tests/ProjectConfigTest.cs @@ -1455,5 +1455,40 @@ public void TestMissingHoldoutsField_BackwardCompatibility() } #endregion + + [Test] + public void TestCmabFieldPopulation() + { + + var datafileJson = JObject.Parse(TestData.Datafile); + var experiments = (JArray)datafileJson["experiments"]; + + if (experiments.Count > 0) + { + var firstExperiment = (JObject)experiments[0]; + + firstExperiment["cmab"] = new JObject + { + ["attributeIds"] = new JArray { "7723280020", "7723348204" }, + ["trafficAllocation"] = 4000 + }; + + firstExperiment["trafficAllocation"] = new JArray(); + } + + var modifiedDatafile = datafileJson.ToString(); + var projectConfig = DatafileProjectConfig.Create(modifiedDatafile, LoggerMock.Object, ErrorHandlerMock.Object); + var experimentWithCmab = projectConfig.GetExperimentFromKey("test_experiment"); + + Assert.IsNotNull(experimentWithCmab.Cmab); + Assert.AreEqual(2, experimentWithCmab.Cmab.AttributeIds.Count); + Assert.Contains("7723280020", experimentWithCmab.Cmab.AttributeIds); + Assert.Contains("7723348204", experimentWithCmab.Cmab.AttributeIds); + Assert.AreEqual(4000, experimentWithCmab.Cmab.TrafficAllocation); + + var experimentWithoutCmab = projectConfig.GetExperimentFromKey("paused_experiment"); + + Assert.IsNull(experimentWithoutCmab.Cmab); + } } } diff --git a/OptimizelySDK/Config/DatafileProjectConfig.cs b/OptimizelySDK/Config/DatafileProjectConfig.cs index e701bc4e..52593e78 100644 --- a/OptimizelySDK/Config/DatafileProjectConfig.cs +++ b/OptimizelySDK/Config/DatafileProjectConfig.cs @@ -195,6 +195,13 @@ private Dictionary> _VariationIdMap public Dictionary AttributeKeyMap => _AttributeKeyMap; + /// + /// Associative array of attribute ID to Attribute(s) in the datafile + /// + private Dictionary _AttributeIdMap; + + public Dictionary AttributeIdMap => _AttributeIdMap; + /// /// Associative array of audience ID to Audience(s) in the datafile /// @@ -332,6 +339,8 @@ private void Initialize() true); _AttributeKeyMap = ConfigParser.GenerateMap(Attributes, a => a.Key, true); + _AttributeIdMap = ConfigParser.GenerateMap(Attributes, + a => a.Id, true); _AudienceIdMap = ConfigParser.GenerateMap(Audiences, a => a.Id.ToString(), true); _FeatureKeyMap = ConfigParser.GenerateMap(FeatureFlags, @@ -653,6 +662,25 @@ public Attribute GetAttribute(string attributeKey) return new Attribute(); } + /// + /// Get the Attribute from the ID + /// + /// ID of the Attribute + /// Attribute Entity corresponding to the ID or a dummy entity if ID is invalid + public Attribute GetAttributeById(string attributeId) + { + if (_AttributeIdMap.ContainsKey(attributeId)) + { + return _AttributeIdMap[attributeId]; + } + + var message = $@"Attribute ID ""{attributeId}"" is not in datafile."; + Logger.Log(LogLevel.ERROR, message); + ErrorHandler.HandleError( + new InvalidAttributeException("Provided attribute is not in datafile.")); + return new Attribute(); + } + /// /// Get the Variation from the keys /// diff --git a/OptimizelySDK/Entity/Cmab.cs b/OptimizelySDK/Entity/Cmab.cs new file mode 100644 index 00000000..f8caec87 --- /dev/null +++ b/OptimizelySDK/Entity/Cmab.cs @@ -0,0 +1,63 @@ +/* + * Copyright 2025, Optimizely + * + * 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. + */ + +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace OptimizelySDK.Entity +{ + /// + /// Class representing CMAB (Contextual Multi-Armed Bandit) configuration for experiments. + /// + public class Cmab + { + /// + /// List of attribute IDs that are relevant for CMAB decision making. + /// These attributes will be used to filter user attributes when making CMAB requests. + /// + [JsonProperty("attributeIds")] + public List AttributeIds { get; set; } + + /// + /// Traffic allocation value for CMAB experiments. + /// Determines what portion of traffic should be allocated to CMAB decision making. + /// + [JsonProperty("trafficAllocation")] + public int? TrafficAllocation { get; set; } + + /// + /// Initializes a new instance of the Cmab class with specified values. + /// + /// List of attribute IDs for CMAB + /// Traffic allocation value + public Cmab(List attributeIds, int? trafficAllocation = null) + { + AttributeIds = attributeIds ?? new List(); + TrafficAllocation = trafficAllocation; + } + + /// + /// Returns a string representation of the CMAB configuration. + /// + /// String representation + public override string ToString() + { + var attributeList = AttributeIds ?? new List(); + return string.Format("Cmab{{AttributeIds=[{0}], TrafficAllocation={1}}}", + string.Join(", ", attributeList.ToArray()), TrafficAllocation); + } + } +} diff --git a/OptimizelySDK/Entity/Experiment.cs b/OptimizelySDK/Entity/Experiment.cs index 8a7b4036..52e99015 100644 --- a/OptimizelySDK/Entity/Experiment.cs +++ b/OptimizelySDK/Entity/Experiment.cs @@ -15,6 +15,7 @@ */ using System.Collections.Generic; +using Newtonsoft.Json; namespace OptimizelySDK.Entity { @@ -48,6 +49,12 @@ public class Experiment : ExperimentCore public bool IsInMutexGroup => !string.IsNullOrEmpty(GroupPolicy) && GroupPolicy == MUTEX_GROUP_POLICY; + /// + /// CMAB (Contextual Multi-Armed Bandit) configuration for the experiment. + /// + [JsonProperty("cmab")] + public Cmab Cmab { get; set; } + /// /// Determin if user is forced variation of experiment /// diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj index 14201b4e..b4537b92 100644 --- a/OptimizelySDK/OptimizelySDK.csproj +++ b/OptimizelySDK/OptimizelySDK.csproj @@ -84,6 +84,7 @@ + diff --git a/OptimizelySDK/ProjectConfig.cs b/OptimizelySDK/ProjectConfig.cs index 992c900b..28f63d24 100644 --- a/OptimizelySDK/ProjectConfig.cs +++ b/OptimizelySDK/ProjectConfig.cs @@ -113,6 +113,11 @@ public interface ProjectConfig /// Dictionary AttributeKeyMap { get; } + /// + /// Associative array of attribute ID to Attribute(s) in the datafile + /// + Dictionary AttributeIdMap { get; } + /// /// Associative array of audience ID to Audience(s) in the datafile /// @@ -234,6 +239,13 @@ public interface ProjectConfig /// Attribute Entity corresponding to the key or a dummy entity if key is invalid Attribute GetAttribute(string attributeKey); + /// + /// Get the Attribute from the ID + /// + /// ID of the Attribute + /// Attribute Entity corresponding to the ID or a dummy entity if ID is invalid + Attribute GetAttributeById(string attributeId); + /// /// Get the Variation from the keys /// diff --git a/OptimizelySDK/Utils/ConfigParser.cs b/OptimizelySDK/Utils/ConfigParser.cs index 318ff1b6..c6b86916 100644 --- a/OptimizelySDK/Utils/ConfigParser.cs +++ b/OptimizelySDK/Utils/ConfigParser.cs @@ -33,7 +33,13 @@ public static Dictionary GenerateMap(IEnumerable entities, Func getKey, bool clone ) { - return entities.ToDictionary(e => getKey(e), e => clone ? (T)e.Clone() : e); + var dictionary = new Dictionary(); + foreach (var entity in entities) + { + var key = getKey(entity); + dictionary[key] = clone ? (T)entity.Clone() : entity; + } + return dictionary; } } } diff --git a/OptimizelySDK/Utils/schema.json b/OptimizelySDK/Utils/schema.json index c434bbac..dd3fedef 100644 --- a/OptimizelySDK/Utils/schema.json +++ b/OptimizelySDK/Utils/schema.json @@ -182,6 +182,20 @@ }, "forcedVariations": { "type": "object" + }, + "cmab": { + "type": "object", + "properties": { + "attributeIds": { + "type": "array", + "items": { + "type": "string" + } + }, + "trafficAllocation": { + "type": "integer" + } + } } }, "required": [ @@ -279,4 +293,4 @@ "version", "revision" ] -} \ No newline at end of file +}