diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
index a4495471..830b41de 100644
--- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
+++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
@@ -88,6 +88,15 @@
Entity\Experiment.cs
+
+ Entity\ExperimentCoreExtensions.cs
+
+
+ Entity\Holdout.cs
+
+
+ Entity\IExperimentCore.cs
+
Entity\FeatureDecision.cs
@@ -215,6 +224,9 @@
Bucketing\ExperimentUtils
+
+ Utils\HoldoutConfig.cs
+
Bucketing\UserProfileUtil
diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
index 05785575..a87e9732 100644
--- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
+++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
@@ -90,6 +90,15 @@
Entity\Experiment.cs
+
+ Entity\ExperimentCoreExtensions.cs
+
+
+ Entity\Holdout.cs
+
+
+ Entity\IExperimentCore.cs
+
Entity\FeatureDecision.cs
@@ -214,6 +223,9 @@
Bucketing\ExperimentUtils
+
+ Utils\HoldoutConfig.cs
+
Bucketing\UserProfileUtil
diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
index b17f79e7..de6c6010 100644
--- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
+++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
@@ -26,6 +26,9 @@
+
+
+
@@ -64,6 +67,7 @@
+
diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
index b7114653..68e10108 100644
--- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
+++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
@@ -178,6 +178,15 @@
Entity\Experiment.cs
+
+ Entity\ExperimentCoreExtensions.cs
+
+
+ Entity\Holdout.cs
+
+
+ Entity\IExperimentCore.cs
+
Entity\FeatureDecision.cs
@@ -331,6 +340,9 @@
Utils\ExperimentUtils.cs
+
+ Utils\HoldoutConfig.cs
+
Utils\Schema.cs
diff --git a/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs b/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
new file mode 100644
index 00000000..a850971d
--- /dev/null
+++ b/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
@@ -0,0 +1,196 @@
+/*
+ * 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;
+using System.IO;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+using OptimizelySDK.Entity;
+
+namespace OptimizelySDK.Tests
+{
+ [TestFixture]
+ public class HoldoutTests
+ {
+ private JObject testData;
+
+ [SetUp]
+ public void Setup()
+ {
+ // Load test data
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ testData = JObject.Parse(jsonContent);
+ }
+
+ [Test]
+ public void TestHoldoutDeserialization()
+ {
+ // Test global holdout deserialization
+ var globalHoldoutJson = testData["globalHoldout"].ToString();
+ var globalHoldout = JsonConvert.DeserializeObject(globalHoldoutJson);
+
+ Assert.IsNotNull(globalHoldout);
+ Assert.AreEqual("holdout_global_1", globalHoldout.Id);
+ Assert.AreEqual("global_holdout", globalHoldout.Key);
+ Assert.AreEqual("Running", globalHoldout.Status);
+ Assert.AreEqual("layer_1", globalHoldout.LayerId);
+ Assert.IsNotNull(globalHoldout.Variations);
+ Assert.AreEqual(1, globalHoldout.Variations.Length);
+ Assert.IsNotNull(globalHoldout.TrafficAllocation);
+ Assert.AreEqual(1, globalHoldout.TrafficAllocation.Length);
+ Assert.IsNotNull(globalHoldout.IncludedFlags);
+ Assert.AreEqual(0, globalHoldout.IncludedFlags.Length);
+ Assert.IsNotNull(globalHoldout.ExcludedFlags);
+ Assert.AreEqual(0, globalHoldout.ExcludedFlags.Length);
+ }
+
+ [Test]
+ public void TestHoldoutWithIncludedFlags()
+ {
+ var includedHoldoutJson = testData["includedFlagsHoldout"].ToString();
+ var includedHoldout = JsonConvert.DeserializeObject(includedHoldoutJson);
+
+ Assert.IsNotNull(includedHoldout);
+ Assert.AreEqual("holdout_included_1", includedHoldout.Id);
+ Assert.AreEqual("included_holdout", includedHoldout.Key);
+ Assert.IsNotNull(includedHoldout.IncludedFlags);
+ Assert.AreEqual(2, includedHoldout.IncludedFlags.Length);
+ Assert.Contains("flag_1", includedHoldout.IncludedFlags);
+ Assert.Contains("flag_2", includedHoldout.IncludedFlags);
+ Assert.IsNotNull(includedHoldout.ExcludedFlags);
+ Assert.AreEqual(0, includedHoldout.ExcludedFlags.Length);
+ }
+
+ [Test]
+ public void TestHoldoutWithExcludedFlags()
+ {
+ var excludedHoldoutJson = testData["excludedFlagsHoldout"].ToString();
+ var excludedHoldout = JsonConvert.DeserializeObject(excludedHoldoutJson);
+
+ Assert.IsNotNull(excludedHoldout);
+ Assert.AreEqual("holdout_excluded_1", excludedHoldout.Id);
+ Assert.AreEqual("excluded_holdout", excludedHoldout.Key);
+ Assert.IsNotNull(excludedHoldout.IncludedFlags);
+ Assert.AreEqual(0, excludedHoldout.IncludedFlags.Length);
+ Assert.IsNotNull(excludedHoldout.ExcludedFlags);
+ Assert.AreEqual(2, excludedHoldout.ExcludedFlags.Length);
+ Assert.Contains("flag_3", excludedHoldout.ExcludedFlags);
+ Assert.Contains("flag_4", excludedHoldout.ExcludedFlags);
+ }
+
+ [Test]
+ public void TestHoldoutWithEmptyFlags()
+ {
+ var globalHoldoutJson = testData["globalHoldout"].ToString();
+ var globalHoldout = JsonConvert.DeserializeObject(globalHoldoutJson);
+
+ Assert.IsNotNull(globalHoldout);
+ Assert.IsNotNull(globalHoldout.IncludedFlags);
+ Assert.AreEqual(0, globalHoldout.IncludedFlags.Length);
+ Assert.IsNotNull(globalHoldout.ExcludedFlags);
+ Assert.AreEqual(0, globalHoldout.ExcludedFlags.Length);
+ }
+
+ [Test]
+ public void TestHoldoutEquality()
+ {
+ var holdoutJson = testData["globalHoldout"].ToString();
+ var holdout1 = JsonConvert.DeserializeObject(holdoutJson);
+ var holdout2 = JsonConvert.DeserializeObject(holdoutJson);
+
+ Assert.IsNotNull(holdout1);
+ Assert.IsNotNull(holdout2);
+ // Note: This test depends on how Holdout implements equality
+ // If Holdout doesn't override Equals, this will test reference equality
+ // You may need to implement custom equality logic for Holdout
+ }
+
+ [Test]
+ public void TestHoldoutStatusParsing()
+ {
+ var globalHoldoutJson = testData["globalHoldout"].ToString();
+ var globalHoldout = JsonConvert.DeserializeObject(globalHoldoutJson);
+
+ Assert.IsNotNull(globalHoldout);
+ Assert.AreEqual("Running", globalHoldout.Status);
+
+ // Test that the holdout is considered activated when status is "Running"
+ // This assumes there's an IsActivated property or similar logic
+ // Adjust based on actual Holdout implementation
+ }
+
+ [Test]
+ public void TestHoldoutVariationsDeserialization()
+ {
+ var holdoutJson = testData["includedFlagsHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ Assert.IsNotNull(holdout);
+ Assert.IsNotNull(holdout.Variations);
+ Assert.AreEqual(1, holdout.Variations.Length);
+
+ var variation = holdout.Variations[0];
+ Assert.AreEqual("var_2", variation.Id);
+ Assert.AreEqual("treatment", variation.Key);
+ Assert.AreEqual(true, variation.FeatureEnabled);
+ }
+
+ [Test]
+ public void TestHoldoutTrafficAllocationDeserialization()
+ {
+ var holdoutJson = testData["excludedFlagsHoldout"].ToString();
+ var holdout = JsonConvert.DeserializeObject(holdoutJson);
+
+ Assert.IsNotNull(holdout);
+ Assert.IsNotNull(holdout.TrafficAllocation);
+ Assert.AreEqual(1, holdout.TrafficAllocation.Length);
+
+ var trafficAllocation = holdout.TrafficAllocation[0];
+ Assert.AreEqual("var_3", trafficAllocation.EntityId);
+ Assert.AreEqual(10000, trafficAllocation.EndOfRange);
+ }
+
+ [Test]
+ public void TestHoldoutNullSafety()
+ {
+ // Test that holdout can handle null/missing includedFlags and excludedFlags
+ var minimalHoldoutJson = @"{
+ ""id"": ""test_holdout"",
+ ""key"": ""test_key"",
+ ""status"": ""Running"",
+ ""layerId"": ""test_layer"",
+ ""variations"": [],
+ ""trafficAllocation"": [],
+ ""audienceIds"": [],
+ ""audienceConditions"": []
+ }";
+
+ var holdout = JsonConvert.DeserializeObject(minimalHoldoutJson);
+
+ Assert.IsNotNull(holdout);
+ Assert.AreEqual("test_holdout", holdout.Id);
+ Assert.AreEqual("test_key", holdout.Key);
+
+ // Verify that missing includedFlags and excludedFlags are handled properly
+ // This depends on how the Holdout entity handles missing properties
+ Assert.IsNotNull(holdout.IncludedFlags);
+ Assert.IsNotNull(holdout.ExcludedFlags);
+ }
+ }
+}
diff --git a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
index 6792d934..1db35b8f 100644
--- a/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
+++ b/OptimizelySDK.Tests/OptimizelySDK.Tests.csproj
@@ -119,6 +119,7 @@
+
@@ -126,12 +127,16 @@
+
+
+ PreserveNewest
+
diff --git a/OptimizelySDK.Tests/ProjectConfigTest.cs b/OptimizelySDK.Tests/ProjectConfigTest.cs
index 55d6e63b..7b4d0d7c 100644
--- a/OptimizelySDK.Tests/ProjectConfigTest.cs
+++ b/OptimizelySDK.Tests/ProjectConfigTest.cs
@@ -16,6 +16,7 @@
using System;
using System.Collections.Generic;
+using System.IO;
using System.Linq;
using Moq;
using Newtonsoft.Json;
@@ -1175,9 +1176,7 @@ public void TestGetAttributeIdWithReservedPrefix()
Assert.AreEqual(reservedAttrConfig.GetAttributeId(reservedPrefixAttrKey),
reservedAttrConfig.GetAttribute(reservedPrefixAttrKey).Id);
LoggerMock.Verify(l => l.Log(LogLevel.WARN,
- $@"Attribute {reservedPrefixAttrKey} unexpectedly has reserved prefix {
- DatafileProjectConfig.RESERVED_ATTRIBUTE_PREFIX
- }; using attribute ID instead of reserved attribute name."));
+ $@"Attribute {reservedPrefixAttrKey} unexpectedly has reserved prefix {DatafileProjectConfig.RESERVED_ATTRIBUTE_PREFIX}; using attribute ID instead of reserved attribute name."));
}
[Test]
@@ -1351,5 +1350,119 @@ public void TestProjectConfigWithOtherIntegrationsInCollection()
Assert.IsNull(datafileProjectConfig.HostForOdp);
Assert.IsNull(datafileProjectConfig.PublicKeyForOdp);
}
+
+ #region Holdout Integration Tests
+
+ [Test]
+ public void TestHoldoutDeserialization_FromDatafile()
+ {
+ // Test that holdouts can be deserialized from a datafile with holdouts
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ var testData = JObject.Parse(jsonContent);
+
+ var datafileJson = testData["datafileWithHoldouts"].ToString();
+
+ var datafileProjectConfig = DatafileProjectConfig.Create(datafileJson,
+ new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig;
+
+ Assert.IsNotNull(datafileProjectConfig.Holdouts);
+ Assert.AreEqual(3, datafileProjectConfig.Holdouts.Length);
+ Assert.IsNotNull(datafileProjectConfig.HoldoutIdMap);
+ Assert.AreEqual(3, datafileProjectConfig.HoldoutIdMap.Count);
+
+ // Verify specific holdouts are present
+ Assert.IsTrue(datafileProjectConfig.HoldoutIdMap.ContainsKey("holdout_global_1"));
+ Assert.IsTrue(datafileProjectConfig.HoldoutIdMap.ContainsKey("holdout_included_1"));
+ Assert.IsTrue(datafileProjectConfig.HoldoutIdMap.ContainsKey("holdout_excluded_1"));
+ }
+
+ [Test]
+ public void TestGetHoldoutsForFlag_Integration()
+ {
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ var testData = JObject.Parse(jsonContent);
+
+ var datafileJson = testData["datafileWithHoldouts"].ToString();
+
+ var datafileProjectConfig = DatafileProjectConfig.Create(datafileJson,
+ new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig;
+
+ // Test GetHoldoutsForFlag method
+ var holdoutsForFlag1 = datafileProjectConfig.GetHoldoutsForFlag("flag_1");
+ Assert.IsNotNull(holdoutsForFlag1);
+ Assert.AreEqual(3, holdoutsForFlag1.Length); // Global + excluded holdout (applies to all except flag_3/flag_4) + included holdout
+
+ var holdoutsForFlag3 = datafileProjectConfig.GetHoldoutsForFlag("flag_3");
+ Assert.IsNotNull(holdoutsForFlag3);
+ Assert.AreEqual(1, holdoutsForFlag3.Length); // Only true global (excluded holdout excludes flag_3)
+
+ var holdoutsForUnknownFlag = datafileProjectConfig.GetHoldoutsForFlag("unknown_flag");
+ Assert.IsNotNull(holdoutsForUnknownFlag);
+ Assert.AreEqual(2, holdoutsForUnknownFlag.Length); // Global + excluded holdout (unknown_flag not in excluded list)
+ }
+
+ [Test]
+ public void TestGetHoldout_Integration()
+ {
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ var testData = JObject.Parse(jsonContent);
+
+ var datafileJson = testData["datafileWithHoldouts"].ToString();
+
+ var datafileProjectConfig = DatafileProjectConfig.Create(datafileJson,
+ new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig;
+
+ // Test GetHoldout method
+ var globalHoldout = datafileProjectConfig.GetHoldout("holdout_global_1");
+ Assert.IsNotNull(globalHoldout);
+ Assert.AreEqual("holdout_global_1", globalHoldout.Id);
+ Assert.AreEqual("global_holdout", globalHoldout.Key);
+
+ var invalidHoldout = datafileProjectConfig.GetHoldout("invalid_id");
+ Assert.IsNull(invalidHoldout);
+ }
+
+ [Test]
+ public void TestMissingHoldoutsField_BackwardCompatibility()
+ {
+ // Test that a datafile without holdouts field still works
+ var datafileWithoutHoldouts = @"{
+ ""version"": ""4"",
+ ""rollouts"": [],
+ ""projectId"": ""test_project"",
+ ""experiments"": [],
+ ""groups"": [],
+ ""attributes"": [],
+ ""audiences"": [],
+ ""layers"": [],
+ ""events"": [],
+ ""revision"": ""1"",
+ ""featureFlags"": []
+ }";
+
+ var datafileProjectConfig = DatafileProjectConfig.Create(datafileWithoutHoldouts,
+ new NoOpLogger(), new NoOpErrorHandler()) as DatafileProjectConfig;
+
+ Assert.IsNotNull(datafileProjectConfig.Holdouts);
+ Assert.AreEqual(0, datafileProjectConfig.Holdouts.Length);
+ Assert.IsNotNull(datafileProjectConfig.HoldoutIdMap);
+ Assert.AreEqual(0, datafileProjectConfig.HoldoutIdMap.Count);
+
+ // Methods should still work with empty holdouts
+ var holdouts = datafileProjectConfig.GetHoldoutsForFlag("any_flag");
+ Assert.IsNotNull(holdouts);
+ Assert.AreEqual(0, holdouts.Length);
+
+ var holdout = datafileProjectConfig.GetHoldout("any_id");
+ Assert.IsNull(holdout);
+ }
+
+ #endregion
}
}
diff --git a/OptimizelySDK.Tests/TestData/HoldoutTestData.json b/OptimizelySDK.Tests/TestData/HoldoutTestData.json
new file mode 100644
index 00000000..b5c17b26
--- /dev/null
+++ b/OptimizelySDK.Tests/TestData/HoldoutTestData.json
@@ -0,0 +1,192 @@
+{
+ "globalHoldout": {
+ "id": "holdout_global_1",
+ "key": "global_holdout",
+ "status": "Running",
+ "layerId": "layer_1",
+ "variations": [
+ {
+ "id": "var_1",
+ "key": "control",
+ "featureEnabled": false,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "var_1",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedFlags": [],
+ "excludedFlags": []
+ },
+ "includedFlagsHoldout": {
+ "id": "holdout_included_1",
+ "key": "included_holdout",
+ "status": "Running",
+ "layerId": "layer_2",
+ "variations": [
+ {
+ "id": "var_2",
+ "key": "treatment",
+ "featureEnabled": true,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "var_2",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedFlags": ["flag_1", "flag_2"],
+ "excludedFlags": []
+ },
+ "excludedFlagsHoldout": {
+ "id": "holdout_excluded_1",
+ "key": "excluded_holdout",
+ "status": "Running",
+ "layerId": "layer_3",
+ "variations": [
+ {
+ "id": "var_3",
+ "key": "excluded_var",
+ "featureEnabled": false,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "var_3",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedFlags": [],
+ "excludedFlags": ["flag_3", "flag_4"]
+ },
+ "datafileWithHoldouts": {
+ "version": "4",
+ "rollouts": [],
+ "projectId": "test_project",
+ "experiments": [],
+ "groups": [],
+ "attributes": [],
+ "audiences": [],
+ "layers": [],
+ "events": [],
+ "revision": "1",
+ "accountId": "12345",
+ "anonymizeIP": false,
+ "featureFlags": [
+ {
+ "id": "flag_1",
+ "key": "test_flag_1",
+ "experimentIds": [],
+ "rolloutId": "",
+ "variables": []
+ },
+ {
+ "id": "flag_2",
+ "key": "test_flag_2",
+ "experimentIds": [],
+ "rolloutId": "",
+ "variables": []
+ },
+ {
+ "id": "flag_3",
+ "key": "test_flag_3",
+ "experimentIds": [],
+ "rolloutId": "",
+ "variables": []
+ },
+ {
+ "id": "flag_4",
+ "key": "test_flag_4",
+ "experimentIds": [],
+ "rolloutId": "",
+ "variables": []
+ }
+ ],
+ "holdouts": [
+ {
+ "id": "holdout_global_1",
+ "key": "global_holdout",
+ "status": "Running",
+ "layerId": "layer_1",
+ "variations": [
+ {
+ "id": "var_1",
+ "key": "control",
+ "featureEnabled": false,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "var_1",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedFlags": [],
+ "excludedFlags": []
+ },
+ {
+ "id": "holdout_included_1",
+ "key": "included_holdout",
+ "status": "Running",
+ "layerId": "layer_2",
+ "variations": [
+ {
+ "id": "var_2",
+ "key": "treatment",
+ "featureEnabled": true,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "var_2",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedFlags": ["flag_1", "flag_2"],
+ "excludedFlags": []
+ },
+ {
+ "id": "holdout_excluded_1",
+ "key": "excluded_holdout",
+ "status": "Running",
+ "layerId": "layer_3",
+ "variations": [
+ {
+ "id": "var_3",
+ "key": "excluded_var",
+ "featureEnabled": false,
+ "variables": []
+ }
+ ],
+ "trafficAllocation": [
+ {
+ "entityId": "var_3",
+ "endOfRange": 10000
+ }
+ ],
+ "audienceIds": [],
+ "audienceConditions": [],
+ "includedFlags": [],
+ "excludedFlags": ["flag_3", "flag_4"]
+ }
+ ]
+ }
+}
diff --git a/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs b/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs
new file mode 100644
index 00000000..23d3ecfd
--- /dev/null
+++ b/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs
@@ -0,0 +1,345 @@
+/*
+ * 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;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using NUnit.Framework;
+using OptimizelySDK.Entity;
+using OptimizelySDK.Utils;
+
+namespace OptimizelySDK.Tests
+{
+ [TestFixture]
+ public class HoldoutConfigTests
+ {
+ private JObject testData;
+ private Holdout globalHoldout;
+ private Holdout includedHoldout;
+ private Holdout excludedHoldout;
+
+ [SetUp]
+ public void Setup()
+ {
+ // Load test data
+ var testDataPath = Path.Combine(TestContext.CurrentContext.TestDirectory,
+ "TestData", "HoldoutTestData.json");
+ var jsonContent = File.ReadAllText(testDataPath);
+ testData = JObject.Parse(jsonContent);
+
+ // Deserialize test holdouts
+ globalHoldout = JsonConvert.DeserializeObject(testData["globalHoldout"].ToString());
+ includedHoldout = JsonConvert.DeserializeObject(testData["includedFlagsHoldout"].ToString());
+ excludedHoldout = JsonConvert.DeserializeObject(testData["excludedFlagsHoldout"].ToString());
+ }
+
+ [Test]
+ public void TestEmptyHoldouts_ShouldHaveEmptyMaps()
+ {
+ var config = new HoldoutConfig(new Holdout[0]);
+
+ Assert.IsNotNull(config.HoldoutIdMap);
+ Assert.AreEqual(0, config.HoldoutIdMap.Count);
+ Assert.IsNotNull(config.GetHoldoutsForFlag("any_flag"));
+ Assert.AreEqual(0, config.GetHoldoutsForFlag("any_flag").Count);
+ }
+
+ [Test]
+ public void TestHoldoutIdMapping()
+ {
+ var allHoldouts = new[] { globalHoldout, includedHoldout, excludedHoldout };
+ var config = new HoldoutConfig(allHoldouts);
+
+ Assert.IsNotNull(config.HoldoutIdMap);
+ Assert.AreEqual(3, config.HoldoutIdMap.Count);
+
+ Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_global_1"));
+ Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_included_1"));
+ Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_excluded_1"));
+
+ Assert.AreEqual(globalHoldout.Id, config.HoldoutIdMap["holdout_global_1"].Id);
+ Assert.AreEqual(includedHoldout.Id, config.HoldoutIdMap["holdout_included_1"].Id);
+ Assert.AreEqual(excludedHoldout.Id, config.HoldoutIdMap["holdout_excluded_1"].Id);
+ }
+
+ [Test]
+ public void TestGetHoldoutById()
+ {
+ var allHoldouts = new[] { globalHoldout, includedHoldout, excludedHoldout };
+ var config = new HoldoutConfig(allHoldouts);
+
+ var retrievedGlobal = config.GetHoldout("holdout_global_1");
+ var retrievedIncluded = config.GetHoldout("holdout_included_1");
+ var retrievedExcluded = config.GetHoldout("holdout_excluded_1");
+
+ Assert.IsNotNull(retrievedGlobal);
+ Assert.AreEqual("holdout_global_1", retrievedGlobal.Id);
+ Assert.AreEqual("global_holdout", retrievedGlobal.Key);
+
+ Assert.IsNotNull(retrievedIncluded);
+ Assert.AreEqual("holdout_included_1", retrievedIncluded.Id);
+ Assert.AreEqual("included_holdout", retrievedIncluded.Key);
+
+ Assert.IsNotNull(retrievedExcluded);
+ Assert.AreEqual("holdout_excluded_1", retrievedExcluded.Id);
+ Assert.AreEqual("excluded_holdout", retrievedExcluded.Key);
+ }
+
+ [Test]
+ public void TestGetHoldoutById_InvalidId()
+ {
+ var allHoldouts = new[] { globalHoldout };
+ var config = new HoldoutConfig(allHoldouts);
+
+ var result = config.GetHoldout("invalid_id");
+ Assert.IsNull(result);
+ }
+
+ [Test]
+ public void TestGlobalHoldoutsForFlag()
+ {
+ var allHoldouts = new[] { globalHoldout };
+ var config = new HoldoutConfig(allHoldouts);
+
+ var holdoutsForFlag = config.GetHoldoutsForFlag("any_flag_id");
+
+ Assert.IsNotNull(holdoutsForFlag);
+ Assert.AreEqual(1, holdoutsForFlag.Count);
+ Assert.AreEqual("holdout_global_1", holdoutsForFlag[0].Id);
+ }
+
+ [Test]
+ public void TestIncludedHoldoutsForFlag()
+ {
+ var allHoldouts = new[] { includedHoldout };
+ var config = new HoldoutConfig(allHoldouts);
+
+ // Test for included flags
+ var holdoutsForFlag1 = config.GetHoldoutsForFlag("flag_1");
+ var holdoutsForFlag2 = config.GetHoldoutsForFlag("flag_2");
+ var holdoutsForOtherFlag = config.GetHoldoutsForFlag("other_flag");
+
+ Assert.IsNotNull(holdoutsForFlag1);
+ Assert.AreEqual(1, holdoutsForFlag1.Count);
+ Assert.AreEqual("holdout_included_1", holdoutsForFlag1[0].Id);
+
+ Assert.IsNotNull(holdoutsForFlag2);
+ Assert.AreEqual(1, holdoutsForFlag2.Count);
+ Assert.AreEqual("holdout_included_1", holdoutsForFlag2[0].Id);
+
+ Assert.IsNotNull(holdoutsForOtherFlag);
+ Assert.AreEqual(0, holdoutsForOtherFlag.Count);
+ }
+
+ [Test]
+ public void TestExcludedHoldoutsForFlag()
+ {
+ var allHoldouts = new[] { excludedHoldout };
+ var config = new HoldoutConfig(allHoldouts);
+
+ // Test for excluded flags - should NOT appear
+ var holdoutsForFlag3 = config.GetHoldoutsForFlag("flag_3");
+ var holdoutsForFlag4 = config.GetHoldoutsForFlag("flag_4");
+ var holdoutsForOtherFlag = config.GetHoldoutsForFlag("other_flag");
+
+ // Excluded flags should not get this holdout
+ Assert.IsNotNull(holdoutsForFlag3);
+ Assert.AreEqual(0, holdoutsForFlag3.Count);
+
+ Assert.IsNotNull(holdoutsForFlag4);
+ Assert.AreEqual(0, holdoutsForFlag4.Count);
+
+ // Other flags should get this global holdout (with exclusions)
+ Assert.IsNotNull(holdoutsForOtherFlag);
+ Assert.AreEqual(1, holdoutsForOtherFlag.Count);
+ Assert.AreEqual("holdout_excluded_1", holdoutsForOtherFlag[0].Id);
+ }
+
+ [Test]
+ public void TestHoldoutOrdering_GlobalThenIncluded()
+ {
+ // Create additional test holdouts with specific IDs for ordering test
+ var global1 = CreateTestHoldout("global_1", "g1", new string[0], new string[0]);
+ var global2 = CreateTestHoldout("global_2", "g2", new string[0], new string[0]);
+ var included = CreateTestHoldout("included_1", "i1", new[] { "test_flag" }, new string[0]);
+
+ var allHoldouts = new[] { included, global1, global2 };
+ var config = new HoldoutConfig(allHoldouts);
+
+ var holdoutsForFlag = config.GetHoldoutsForFlag("test_flag");
+
+ Assert.IsNotNull(holdoutsForFlag);
+ Assert.AreEqual(3, holdoutsForFlag.Count);
+
+ // Should be: global1, global2, included (global first, then included)
+ var ids = holdoutsForFlag.Select(h => h.Id).ToArray();
+ Assert.Contains("global_1", ids);
+ Assert.Contains("global_2", ids);
+ Assert.Contains("included_1", ids);
+
+ // Included should be last (after globals)
+ Assert.AreEqual("included_1", holdoutsForFlag.Last().Id);
+ }
+
+ [Test]
+ public void TestComplexFlagScenarios_MultipleRules()
+ {
+ var global1 = CreateTestHoldout("global_1", "g1", new string[0], new string[0]);
+ var global2 = CreateTestHoldout("global_2", "g2", new string[0], new string[0]);
+ var included = CreateTestHoldout("included_1", "i1", new[] { "flag_1" }, new string[0]);
+ var excluded = CreateTestHoldout("excluded_1", "e1", new string[0], new[] { "flag_2" });
+
+ var allHoldouts = new[] { included, excluded, global1, global2 };
+ var config = new HoldoutConfig(allHoldouts);
+
+ // Test flag_1: should get globals + excluded global + included
+ var holdoutsForFlag1 = config.GetHoldoutsForFlag("flag_1");
+ Assert.AreEqual(4, holdoutsForFlag1.Count);
+ var flag1Ids = holdoutsForFlag1.Select(h => h.Id).ToArray();
+ Assert.Contains("global_1", flag1Ids);
+ Assert.Contains("global_2", flag1Ids);
+ Assert.Contains("excluded_1", flag1Ids); // excluded global should appear for other flags
+ Assert.Contains("included_1", flag1Ids);
+
+ // Test flag_2: should get only regular globals (excluded global should NOT appear)
+ var holdoutsForFlag2 = config.GetHoldoutsForFlag("flag_2");
+ Assert.AreEqual(2, holdoutsForFlag2.Count);
+ var flag2Ids = holdoutsForFlag2.Select(h => h.Id).ToArray();
+ Assert.Contains("global_1", flag2Ids);
+ Assert.Contains("global_2", flag2Ids);
+ Assert.IsFalse(flag2Ids.Contains("excluded_1")); // Should be excluded
+ Assert.IsFalse(flag2Ids.Contains("included_1")); // Not included for this flag
+
+ // Test flag_3: should get globals + excluded global
+ var holdoutsForFlag3 = config.GetHoldoutsForFlag("flag_3");
+ Assert.AreEqual(3, holdoutsForFlag3.Count);
+ var flag3Ids = holdoutsForFlag3.Select(h => h.Id).ToArray();
+ Assert.Contains("global_1", flag3Ids);
+ Assert.Contains("global_2", flag3Ids);
+ Assert.Contains("excluded_1", flag3Ids);
+ }
+
+ [Test]
+ public void TestExcludedHoldout_ShouldNotAppearInGlobal()
+ {
+ var global = CreateTestHoldout("global_1", "global", new string[0], new string[0]);
+ var excluded = CreateTestHoldout("excluded_1", "excluded", new string[0], new[] { "target_flag" });
+
+ var allHoldouts = new[] { global, excluded };
+ var config = new HoldoutConfig(allHoldouts);
+
+ var holdoutsForTargetFlag = config.GetHoldoutsForFlag("target_flag");
+
+ Assert.IsNotNull(holdoutsForTargetFlag);
+ Assert.AreEqual(1, holdoutsForTargetFlag.Count);
+ Assert.AreEqual("global_1", holdoutsForTargetFlag[0].Id);
+ // excluded should NOT appear for target_flag
+ }
+
+ [Test]
+ public void TestCaching_SecondCallUsesCachedResult()
+ {
+ var allHoldouts = new[] { globalHoldout, includedHoldout };
+ var config = new HoldoutConfig(allHoldouts);
+
+ // First call
+ var firstResult = config.GetHoldoutsForFlag("flag_1");
+
+ // Second call - should use cache
+ var secondResult = config.GetHoldoutsForFlag("flag_1");
+
+ Assert.IsNotNull(firstResult);
+ Assert.IsNotNull(secondResult);
+ Assert.AreEqual(firstResult.Count, secondResult.Count);
+
+ // Results should be the same (caching working)
+ for (int i = 0; i < firstResult.Count; i++)
+ {
+ Assert.AreEqual(firstResult[i].Id, secondResult[i].Id);
+ }
+ }
+
+ [Test]
+ public void TestNullFlagId_ReturnsEmptyList()
+ {
+ var config = new HoldoutConfig(new[] { globalHoldout });
+
+ var result = config.GetHoldoutsForFlag(null);
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(0, result.Count);
+ }
+
+ [Test]
+ public void TestEmptyFlagId_ReturnsEmptyList()
+ {
+ var config = new HoldoutConfig(new[] { globalHoldout });
+
+ var result = config.GetHoldoutsForFlag("");
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(0, result.Count);
+ }
+
+ [Test]
+ public void TestGetHoldoutsForFlag_WithNullHoldouts()
+ {
+ var config = new HoldoutConfig(null);
+
+ var result = config.GetHoldoutsForFlag("any_flag");
+
+ Assert.IsNotNull(result);
+ Assert.AreEqual(0, result.Count);
+ }
+
+ [Test]
+ public void TestUpdateHoldoutMapping()
+ {
+ var config = new HoldoutConfig(new[] { globalHoldout });
+
+ // Initial state
+ Assert.AreEqual(1, config.HoldoutIdMap.Count);
+
+ // Update with new holdouts
+ config.UpdateHoldoutMapping(new[] { globalHoldout, includedHoldout });
+
+ Assert.AreEqual(2, config.HoldoutIdMap.Count);
+ Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_global_1"));
+ Assert.IsTrue(config.HoldoutIdMap.ContainsKey("holdout_included_1"));
+ }
+
+ // Helper method to create test holdouts
+ private Holdout CreateTestHoldout(string id, string key, string[] includedFlags, string[] excludedFlags)
+ {
+ return new Holdout
+ {
+ Id = id,
+ Key = key,
+ Status = "Running",
+ LayerId = "test_layer",
+ Variations = new Variation[0],
+ TrafficAllocation = new TrafficAllocation[0],
+ AudienceIds = new string[0],
+ AudienceConditions = null,
+ IncludedFlags = includedFlags,
+ ExcludedFlags = excludedFlags
+ };
+ }
+ }
+}
diff --git a/OptimizelySDK/Config/DatafileProjectConfig.cs b/OptimizelySDK/Config/DatafileProjectConfig.cs
index cb248f8c..419ff03c 100644
--- a/OptimizelySDK/Config/DatafileProjectConfig.cs
+++ b/OptimizelySDK/Config/DatafileProjectConfig.cs
@@ -216,6 +216,13 @@ private Dictionary> _VariationIdMap
public Dictionary RolloutIdMap => _RolloutIdMap;
+ ///
+ /// Associative array of Holdout ID to Holdout(s) in the datafile
+ ///
+ private Dictionary _HoldoutIdMap;
+
+ public Dictionary HoldoutIdMap => _HoldoutIdMap;
+
///
/// Associative array of experiment IDs that exist in any feature
/// for checking that experiment is a feature experiment.
@@ -232,6 +239,11 @@ private Dictionary> _VariationIdMap
public Dictionary> FlagVariationMap =>
_FlagVariationMap;
+ ///
+ /// Holdout configuration manager for flag-to-holdout relationships.
+ ///
+ private HoldoutConfig _holdoutConfig;
+
//========================= Interfaces ===========================
///
@@ -286,6 +298,11 @@ private Dictionary> _VariationIdMap
///
public Rollout[] Rollouts { get; set; }
+ ///
+ /// Associative list of Holdouts.
+ ///
+ public Holdout[] Holdouts { get; set; }
+
///
/// Associative list of Integrations.
///
@@ -309,6 +326,7 @@ private void Initialize()
TypedAudiences = TypedAudiences ?? new Audience[0];
FeatureFlags = FeatureFlags ?? new FeatureFlag[0];
Rollouts = Rollouts ?? new Rollout[0];
+ Holdouts = Holdouts ?? new Holdout[0];
Integrations = Integrations ?? new Integration[0];
_ExperimentKeyMap = new Dictionary();
@@ -327,6 +345,8 @@ private void Initialize()
f => f.Key, true);
_RolloutIdMap = ConfigParser.GenerateMap(Rollouts,
r => r.Id.ToString(), true);
+ _HoldoutIdMap = ConfigParser.GenerateMap(Holdouts,
+ h => h.Id, true);
// Overwrite similar items in audience id map with typed audience id map.
var typedAudienceIdMap = ConfigParser.GenerateMap(TypedAudiences,
@@ -450,6 +470,9 @@ private void Initialize()
}
_FlagVariationMap = flagToVariationsMap;
+
+ // Initialize HoldoutConfig for managing flag-to-holdout relationships
+ _holdoutConfig = new HoldoutConfig(Holdouts ?? new Holdout[0]);
}
///
@@ -492,8 +515,7 @@ private static DatafileProjectConfig GetConfig(string configData)
!(((int)supportedVersion).ToString() == config.Version)))
{
throw new ConfigParseException(
- $@"This version of the C# SDK does not support the given datafile version: {
- config.Version}");
+ $@"This version of the C# SDK does not support the given datafile version: {config.Version}");
}
return config;
@@ -632,8 +654,7 @@ public Variation GetVariationFromKey(string experimentKey, string variationKey)
return _VariationKeyMap[experimentKey][variationKey];
}
- var message = $@"No variation key ""{variationKey
- }"" defined in datafile for experiment ""{experimentKey}"".";
+ var message = $@"No variation key ""{variationKey}"" defined in datafile for experiment ""{experimentKey}"".";
Logger.Log(LogLevel.ERROR, message);
ErrorHandler.HandleError(
new InvalidVariationException("Provided variation is not in datafile."));
@@ -655,8 +676,7 @@ public Variation GetVariationFromKeyByExperimentId(string experimentId, string v
return _VariationKeyMapByExperimentId[experimentId][variationKey];
}
- var message = $@"No variation key ""{variationKey
- }"" defined in datafile for experiment ""{experimentId}"".";
+ var message = $@"No variation key ""{variationKey}"" defined in datafile for experiment ""{experimentId}"".";
Logger.Log(LogLevel.ERROR, message);
ErrorHandler.HandleError(
new InvalidVariationException("Provided variation is not in datafile."));
@@ -678,8 +698,7 @@ public Variation GetVariationFromId(string experimentKey, string variationId)
return _VariationIdMap[experimentKey][variationId];
}
- var message = $@"No variation ID ""{variationId
- }"" defined in datafile for experiment ""{experimentKey}"".";
+ var message = $@"No variation ID ""{variationId}"" defined in datafile for experiment ""{experimentKey}"".";
Logger.Log(LogLevel.ERROR, message);
ErrorHandler.HandleError(
new InvalidVariationException("Provided variation is not in datafile."));
@@ -701,8 +720,7 @@ public Variation GetVariationFromIdByExperimentId(string experimentId, string va
return _VariationIdMapByExperimentId[experimentId][variationId];
}
- var message = $@"No variation ID ""{variationId
- }"" defined in datafile for experiment ""{experimentId}"".";
+ var message = $@"No variation ID ""{variationId}"" defined in datafile for experiment ""{experimentId}"".";
Logger.Log(LogLevel.ERROR, message);
ErrorHandler.HandleError(
new InvalidVariationException("Provided variation is not in datafile."));
@@ -773,6 +791,34 @@ public Rollout GetRolloutFromId(string rolloutId)
return new Rollout();
}
+ ///
+ /// Get the holdout from the ID
+ ///
+ /// ID for holdout
+ /// Holdout Entity corresponding to the holdout ID or null if ID is invalid
+ public Holdout GetHoldout(string holdoutId)
+ {
+#if NET35 || NET40
+ if (string.IsNullOrEmpty(holdoutId) || string.IsNullOrEmpty(holdoutId.Trim()))
+#else
+ if (string.IsNullOrWhiteSpace(holdoutId))
+#endif
+ {
+ return null;
+ }
+
+ if (_HoldoutIdMap.ContainsKey(holdoutId))
+ {
+ return _HoldoutIdMap[holdoutId];
+ }
+
+ var message = $@"Holdout ID ""{holdoutId}"" is not in datafile.";
+ Logger.Log(LogLevel.ERROR, message);
+ ErrorHandler.HandleError(
+ new InvalidExperimentException("Provided holdout is not in datafile."));
+ return null;
+ }
+
///
/// Get attribute ID for the provided attribute key
///
@@ -788,9 +834,7 @@ public string GetAttributeId(string attributeKey)
if (hasReservedPrefix)
{
Logger.Log(LogLevel.WARN,
- $@"Attribute {attributeKey} unexpectedly has reserved prefix {
- RESERVED_ATTRIBUTE_PREFIX
- }; using attribute ID instead of reserved attribute name.");
+ $@"Attribute {attributeKey} unexpectedly has reserved prefix {RESERVED_ATTRIBUTE_PREFIX}; using attribute ID instead of reserved attribute name.");
}
return attribute.Id;
@@ -832,5 +876,16 @@ public string ToDatafile()
{
return _datafile;
}
+
+ ///
+ /// Get holdout instances associated with the given feature flag key.
+ ///
+ /// Feature flag key
+ /// Array of holdouts associated with the flag, empty array if none
+ public Holdout[] GetHoldoutsForFlag(string flagKey)
+ {
+ var holdouts = _holdoutConfig?.GetHoldoutsForFlag(flagKey);
+ return holdouts?.ToArray() ?? new Holdout[0];
+ }
}
}
diff --git a/OptimizelySDK/Entity/Experiment.cs b/OptimizelySDK/Entity/Experiment.cs
index e1eee5f2..b0079365 100644
--- a/OptimizelySDK/Entity/Experiment.cs
+++ b/OptimizelySDK/Entity/Experiment.cs
@@ -22,7 +22,7 @@
namespace OptimizelySDK.Entity
{
- public class Experiment : IdKeyEntity
+ public class Experiment : IdKeyEntity, IExperimentCore
{
private const string STATUS_RUNNING = "Running";
@@ -281,5 +281,10 @@ public bool IsUserInForcedVariation(string userId)
{
return ForcedVariations != null && ForcedVariations.ContainsKey(userId);
}
+
+ ///
+ /// Determine if experiment is currently activated/running (IExperimentCore implementation)
+ ///
+ public bool IsActivated => IsExperimentRunning;
}
}
diff --git a/OptimizelySDK/Entity/ExperimentCoreExtensions.cs b/OptimizelySDK/Entity/ExperimentCoreExtensions.cs
new file mode 100644
index 00000000..90f3996d
--- /dev/null
+++ b/OptimizelySDK/Entity/ExperimentCoreExtensions.cs
@@ -0,0 +1,127 @@
+/*
+ * 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.Linq;
+
+namespace OptimizelySDK.Entity
+{
+ ///
+ /// Extension methods providing common functionality for IExperimentCore implementations
+ ///
+ public static class ExperimentCoreExtensions
+ {
+ ///
+ /// Get variation by ID
+ ///
+ /// The experiment or holdout instance
+ /// Variation ID to search for
+ /// Variation with the specified ID, or null if not found
+ public static Variation GetVariation(this IExperimentCore experimentCore, string id)
+ {
+ if (experimentCore?.Variations == null || string.IsNullOrEmpty(id))
+ {
+ return null;
+ }
+
+ return experimentCore.Variations.FirstOrDefault(v => v.Id == id);
+ }
+
+ ///
+ /// Get variation by key
+ ///
+ /// The experiment or holdout instance
+ /// Variation key to search for
+ /// Variation with the specified key, or null if not found
+ public static Variation GetVariationByKey(this IExperimentCore experimentCore, string key)
+ {
+ if (experimentCore?.Variations == null || string.IsNullOrEmpty(key))
+ {
+ return null;
+ }
+
+ return experimentCore.Variations.FirstOrDefault(v => v.Key == key);
+ }
+
+ ///
+ /// Replace audience IDs with audience names in a condition string
+ ///
+ /// The experiment or holdout instance
+ /// String containing audience conditions
+ /// Map of audience ID to audience name
+ /// String with audience IDs replaced by names
+ public static string ReplaceAudienceIdsWithNames(this IExperimentCore experimentCore,
+ string conditionString, System.Collections.Generic.Dictionary audiencesMap)
+ {
+ if (string.IsNullOrEmpty(conditionString) || audiencesMap == null)
+ {
+ return conditionString ?? string.Empty;
+ }
+
+ const string beginWord = "AUDIENCE(";
+ const string endWord = ")";
+ var keyIdx = 0;
+ var audienceId = string.Empty;
+ var collect = false;
+ var replaced = string.Empty;
+
+ foreach (var ch in conditionString)
+ {
+ // Extract audience id in parenthesis (example: AUDIENCE("35") => "35")
+ if (collect)
+ {
+ if (ch.ToString() == endWord)
+ {
+ // Output the extracted audienceId
+ var audienceName = audiencesMap.ContainsKey(audienceId) ? audiencesMap[audienceId] : audienceId;
+ replaced += $"\"{audienceName}\"";
+ collect = false;
+ audienceId = string.Empty;
+ }
+ else
+ {
+ audienceId += ch;
+ }
+ continue;
+ }
+
+ // Walk-through until finding a matching keyword "AUDIENCE("
+ if (ch == beginWord[keyIdx])
+ {
+ keyIdx++;
+ if (keyIdx == beginWord.Length)
+ {
+ keyIdx = 0;
+ collect = true;
+ }
+ continue;
+ }
+ else
+ {
+ if (keyIdx > 0)
+ {
+ replaced += beginWord.Substring(0, keyIdx);
+ }
+ keyIdx = 0;
+ }
+
+ // Pass through other characters
+ replaced += ch;
+ }
+
+ return replaced;
+ }
+ }
+}
diff --git a/OptimizelySDK/Entity/Holdout.cs b/OptimizelySDK/Entity/Holdout.cs
new file mode 100644
index 00000000..a8ed53fe
--- /dev/null
+++ b/OptimizelySDK/Entity/Holdout.cs
@@ -0,0 +1,284 @@
+/*
+ * 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 System.Linq;
+using Newtonsoft.Json;
+using Newtonsoft.Json.Linq;
+using OptimizelySDK.AudienceConditions;
+using OptimizelySDK.Utils;
+
+namespace OptimizelySDK.Entity
+{
+ ///
+ /// Represents a holdout in an Optimizely project
+ ///
+ public class Holdout : IdKeyEntity, IExperimentCore
+ {
+ ///
+ /// Holdout status enumeration
+ ///
+ public enum HoldoutStatus
+ {
+ Draft,
+ Running,
+ Concluded,
+ Archived
+ }
+
+ private const string STATUS_RUNNING = "Running";
+
+ ///
+ /// Holdout Status
+ ///
+ public string Status { get; set; }
+
+ ///
+ /// Layer ID for the holdout
+ ///
+ public string LayerId { get; set; }
+
+ ///
+ /// Variations for the holdout
+ ///
+ public Variation[] Variations { get; set; }
+
+ ///
+ /// Traffic allocation of variations in the holdout
+ ///
+ public TrafficAllocation[] TrafficAllocation { get; set; }
+
+ ///
+ /// ID(s) of audience(s) the holdout is targeted to
+ ///
+ public string[] AudienceIds { get; set; }
+
+ ///
+ /// Audience Conditions
+ ///
+ public object AudienceConditions { get; set; }
+
+ ///
+ /// Flags included in this holdout
+ ///
+ public string[] IncludedFlags { get; set; } = new string[0];
+
+ ///
+ /// Flags excluded from this holdout
+ ///
+ public string[] ExcludedFlags { get; set; } = new string[0];
+
+ #region Audience Processing Properties
+
+ private ICondition _audienceIdsList = null;
+
+ ///
+ /// De-serialized audience conditions from audience IDs
+ ///
+ public ICondition AudienceIdsList
+ {
+ get
+ {
+ if (AudienceIds == null || AudienceIds.Length == 0)
+ {
+ return null;
+ }
+
+ if (_audienceIdsList == null)
+ {
+ var conditions = new List();
+ foreach (var audienceId in AudienceIds)
+ {
+ conditions.Add(new AudienceIdCondition() { AudienceId = audienceId });
+ }
+
+ _audienceIdsList = new OrCondition() { Conditions = conditions.ToArray() };
+ }
+
+ return _audienceIdsList;
+ }
+ }
+
+ private string _audienceIdsString = null;
+
+ ///
+ /// Stringified audience IDs
+ ///
+ public string AudienceIdsString
+ {
+ get
+ {
+ if (AudienceIds == null)
+ {
+ return null;
+ }
+
+ if (_audienceIdsString == null)
+ {
+ _audienceIdsString = JsonConvert.SerializeObject(AudienceIds, Formatting.None);
+ }
+
+ return _audienceIdsString;
+ }
+ }
+
+ private ICondition _audienceConditionsList = null;
+
+ ///
+ /// De-serialized audience conditions
+ ///
+ public ICondition AudienceConditionsList
+ {
+ get
+ {
+ if (AudienceConditions == null)
+ {
+ return null;
+ }
+
+ if (_audienceConditionsList == null)
+ {
+ if (AudienceConditions is string)
+ {
+ _audienceConditionsList =
+ ConditionParser.ParseAudienceConditions(
+ JToken.Parse((string)AudienceConditions));
+ }
+ else
+ {
+ _audienceConditionsList =
+ ConditionParser.ParseAudienceConditions((JToken)AudienceConditions);
+ }
+ }
+
+ return _audienceConditionsList;
+ }
+ }
+
+ private string _audienceConditionsString = null;
+
+ ///
+ /// Stringified audience conditions
+ ///
+ public string AudienceConditionsString
+ {
+ get
+ {
+ if (AudienceConditions == null)
+ {
+ return null;
+ }
+
+ if (_audienceConditionsString == null)
+ {
+ if (AudienceConditions is JToken token)
+ {
+ _audienceConditionsString = token.ToString(Formatting.None);
+ }
+ else
+ {
+ _audienceConditionsString = AudienceConditions.ToString();
+ }
+ }
+
+ return _audienceConditionsString;
+ }
+ }
+
+ #endregion
+
+ #region Variation Mapping Properties
+
+ private bool isGenerateKeyMapCalled = false;
+
+ private Dictionary _VariationKeyToVariationMap;
+
+ ///
+ /// Variation key to variation mapping
+ ///
+ public Dictionary VariationKeyToVariationMap
+ {
+ get
+ {
+ if (!isGenerateKeyMapCalled)
+ {
+ GenerateVariationKeyMap();
+ }
+
+ return _VariationKeyToVariationMap;
+ }
+ }
+
+ private Dictionary _VariationIdToVariationMap;
+
+ ///
+ /// Variation ID to variation mapping
+ ///
+ public Dictionary VariationIdToVariationMap
+ {
+ get
+ {
+ if (!isGenerateKeyMapCalled)
+ {
+ GenerateVariationKeyMap();
+ }
+
+ return _VariationIdToVariationMap;
+ }
+ }
+
+ ///
+ /// Generate variation key maps for performance optimization
+ ///
+ public void GenerateVariationKeyMap()
+ {
+ if (Variations == null)
+ {
+ return;
+ }
+
+ _VariationIdToVariationMap =
+ ConfigParser.GenerateMap(Variations, a => a.Id, true);
+ _VariationKeyToVariationMap =
+ ConfigParser.GenerateMap(Variations, a => a.Key, true);
+ isGenerateKeyMapCalled = true;
+ }
+
+ #endregion
+
+ ///
+ /// Determine if holdout is currently activated/running
+ ///
+ public bool IsActivated =>
+ !string.IsNullOrEmpty(Status) && Status == STATUS_RUNNING;
+
+ ///
+ /// Serializes audiences with provided audience map for display purposes
+ ///
+ /// Map of audience ID to audience name
+ /// Serialized audience string with names
+ public string SerializeAudiences(Dictionary audiencesMap)
+ {
+ if (AudienceConditions == null)
+ {
+ return string.Empty;
+ }
+
+ var serialized = AudienceConditionsString;
+ return this.ReplaceAudienceIdsWithNames(serialized, audiencesMap);
+ }
+ }
+}
diff --git a/OptimizelySDK/Entity/IExperimentCore.cs b/OptimizelySDK/Entity/IExperimentCore.cs
new file mode 100644
index 00000000..297442cc
--- /dev/null
+++ b/OptimizelySDK/Entity/IExperimentCore.cs
@@ -0,0 +1,107 @@
+/*
+ * 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 OptimizelySDK.AudienceConditions;
+
+namespace OptimizelySDK.Entity
+{
+ ///
+ /// Interface defining common properties and behaviors shared between Experiment and Holdout
+ ///
+ public interface IExperimentCore
+ {
+ ///
+ /// Entity ID
+ ///
+ string Id { get; set; }
+
+ ///
+ /// Entity Key
+ ///
+ string Key { get; set; }
+
+ ///
+ /// Status of the experiment/holdout
+ ///
+ string Status { get; set; }
+
+ ///
+ /// Layer ID for the experiment/holdout
+ ///
+ string LayerId { get; set; }
+
+ ///
+ /// Variations for the experiment/holdout
+ ///
+ Variation[] Variations { get; set; }
+
+ ///
+ /// Traffic allocation of variations in the experiment/holdout
+ ///
+ TrafficAllocation[] TrafficAllocation { get; set; }
+
+ ///
+ /// ID(s) of audience(s) the experiment/holdout is targeted to
+ ///
+ string[] AudienceIds { get; set; }
+
+ ///
+ /// Audience Conditions
+ ///
+ object AudienceConditions { get; set; }
+
+ ///
+ /// De-serialized audience conditions
+ ///
+ ICondition AudienceConditionsList { get; }
+
+ ///
+ /// Stringified audience conditions
+ ///
+ string AudienceConditionsString { get; }
+
+ ///
+ /// De-serialized audience conditions from audience IDs
+ ///
+ ICondition AudienceIdsList { get; }
+
+ ///
+ /// Stringified audience IDs
+ ///
+ string AudienceIdsString { get; }
+
+ ///
+ /// Variation key to variation mapping
+ ///
+ Dictionary VariationKeyToVariationMap { get; }
+
+ ///
+ /// Variation ID to variation mapping
+ ///
+ Dictionary VariationIdToVariationMap { get; }
+
+ ///
+ /// Determine if experiment/holdout is currently activated/running
+ ///
+ bool IsActivated { get; }
+
+ ///
+ /// Generate variation key maps for performance optimization
+ ///
+ void GenerateVariationKeyMap();
+ }
+}
diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj
index 5f041ac1..a8cafd73 100644
--- a/OptimizelySDK/OptimizelySDK.csproj
+++ b/OptimizelySDK/OptimizelySDK.csproj
@@ -84,6 +84,9 @@
+
+
+
@@ -171,6 +174,7 @@
+
diff --git a/OptimizelySDK/ProjectConfig.cs b/OptimizelySDK/ProjectConfig.cs
index 58272aa7..338dc577 100644
--- a/OptimizelySDK/ProjectConfig.cs
+++ b/OptimizelySDK/ProjectConfig.cs
@@ -128,6 +128,11 @@ public interface ProjectConfig
///
Dictionary RolloutIdMap { get; }
+ ///
+ /// Associative array of Holdout ID to Holdout(s) in the datafile
+ ///
+ Dictionary HoldoutIdMap { get; }
+
///
/// Associative dictionary of Flag to Variation key and Variation in the datafile
///
@@ -175,6 +180,11 @@ public interface ProjectConfig
///
Rollout[] Rollouts { get; set; }
+ ///
+ /// Associative list of Holdouts.
+ ///
+ Holdout[] Holdouts { get; set; }
+
///
/// Associative list of Integrations.
///
@@ -308,6 +318,20 @@ public interface ProjectConfig
/// List| Feature flag ids list, null otherwise
List GetExperimentFeatureList(string experimentId);
+ ///
+ /// Get the holdout from the ID
+ ///
+ /// ID for holdout
+ /// Holdout Entity corresponding to the holdout ID or null if ID is invalid
+ Holdout GetHoldout(string holdoutId);
+
+ ///
+ /// Get holdout instances associated with the given feature flag key.
+ ///
+ /// Feature flag key
+ /// Array of holdouts associated with the flag, empty array if none
+ Holdout[] GetHoldoutsForFlag(string flagKey);
+
///
/// Returns the datafile corresponding to ProjectConfig
///
diff --git a/OptimizelySDK/Utils/HoldoutConfig.cs b/OptimizelySDK/Utils/HoldoutConfig.cs
new file mode 100644
index 00000000..a3afd38c
--- /dev/null
+++ b/OptimizelySDK/Utils/HoldoutConfig.cs
@@ -0,0 +1,186 @@
+/*
+ * 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
+ *
+ * https://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 System.Linq;
+using OptimizelySDK.Entity;
+
+namespace OptimizelySDK.Utils
+{
+ ///
+ /// Configuration manager for holdouts, providing flag-to-holdout relationship mapping and optimization logic.
+ ///
+ public class HoldoutConfig
+ {
+ private List _allHoldouts;
+ private readonly List _globalHoldouts;
+ private readonly Dictionary _holdoutIdMap;
+ private readonly Dictionary> _includedHoldouts;
+ private readonly Dictionary> _excludedHoldouts;
+ private readonly Dictionary> _flagHoldoutCache;
+
+ ///
+ /// Initializes a new instance of the HoldoutConfig class.
+ ///
+ /// Array of all holdouts from the datafile
+ public HoldoutConfig(Holdout[] allHoldouts = null)
+ {
+ _allHoldouts = allHoldouts?.ToList() ?? new List();
+ _globalHoldouts = new List();
+ _holdoutIdMap = new Dictionary();
+ _includedHoldouts = new Dictionary>();
+ _excludedHoldouts = new Dictionary>();
+ _flagHoldoutCache = new Dictionary>();
+
+ UpdateHoldoutMapping();
+ }
+
+ ///
+ /// Gets a read-only dictionary mapping holdout IDs to holdout instances.
+ ///
+ public IDictionary HoldoutIdMap => _holdoutIdMap;
+
+ ///
+ /// Updates internal mappings of holdouts including the id map, global list, and per-flag inclusion/exclusion maps.
+ ///
+ private void UpdateHoldoutMapping()
+ {
+ // Clear existing mappings
+ _holdoutIdMap.Clear();
+ _globalHoldouts.Clear();
+ _includedHoldouts.Clear();
+ _excludedHoldouts.Clear();
+ _flagHoldoutCache.Clear();
+
+ foreach (var holdout in _allHoldouts)
+ {
+ // Build ID mapping
+ _holdoutIdMap[holdout.Id] = holdout;
+
+ var hasIncludedFlags = holdout.IncludedFlags != null && holdout.IncludedFlags.Length > 0;
+ var hasExcludedFlags = holdout.ExcludedFlags != null && holdout.ExcludedFlags.Length > 0;
+
+ if (hasIncludedFlags)
+ {
+ // Local/targeted holdout - only applies to specific included flags
+ foreach (var flagId in holdout.IncludedFlags)
+ {
+ if (!_includedHoldouts.ContainsKey(flagId))
+ _includedHoldouts[flagId] = new List();
+
+ _includedHoldouts[flagId].Add(holdout);
+ }
+ }
+ else
+ {
+ // Global holdout (applies to all flags)
+ _globalHoldouts.Add(holdout);
+
+ // If it has excluded flags, track which flags to exclude it from
+ if (hasExcludedFlags)
+ {
+ foreach (var flagId in holdout.ExcludedFlags)
+ {
+ if (!_excludedHoldouts.ContainsKey(flagId))
+ _excludedHoldouts[flagId] = new List();
+
+ _excludedHoldouts[flagId].Add(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.
+ ///
+ /// The flag identifier
+ /// A list of Holdout objects relevant to the given flag
+ public List GetHoldoutsForFlag(string flagId)
+ {
+ if (string.IsNullOrEmpty(flagId) || _allHoldouts.Count == 0)
+ return new List();
+
+ // Check cache first
+ if (_flagHoldoutCache.ContainsKey(flagId))
+ return _flagHoldoutCache[flagId];
+
+ var activeHoldouts = new List();
+ // Start with global holdouts, excluding any that are specifically excluded for this flag
+ var excludedForFlag = _excludedHoldouts.ContainsKey(flagId) ? _excludedHoldouts[flagId] : new List();
+
+ if (excludedForFlag.Count > 0)
+ {
+ // Only iterate if we have exclusions to check
+ foreach (var globalHoldout in _globalHoldouts)
+ {
+ if (!excludedForFlag.Contains(globalHoldout))
+ {
+ activeHoldouts.Add(globalHoldout);
+ }
+ }
+ }
+ else
+ {
+ // No exclusions, add all global holdouts directly
+ activeHoldouts.AddRange(_globalHoldouts);
+ }
+
+ // Add included holdouts for this flag
+ if (_includedHoldouts.ContainsKey(flagId))
+ {
+ activeHoldouts.AddRange(_includedHoldouts[flagId]);
+ }
+
+ // Cache the result
+ _flagHoldoutCache[flagId] = activeHoldouts;
+
+ return activeHoldouts;
+ }
+
+ ///
+ /// Get a Holdout object for an ID.
+ ///
+ /// The holdout identifier
+ /// The Holdout object if found, null otherwise
+ public Holdout GetHoldout(string holdoutId)
+ {
+ return _holdoutIdMap.ContainsKey(holdoutId) ? _holdoutIdMap[holdoutId] : null;
+ }
+
+ ///
+ /// Gets the total number of holdouts.
+ ///
+ public int HoldoutCount => _allHoldouts.Count;
+
+ ///
+ /// Gets the number of global holdouts.
+ ///
+ public int GlobalHoldoutCount => _globalHoldouts.Count;
+
+ ///
+ /// Updates the holdout configuration with a new set of holdouts.
+ /// This method is useful for testing or when the holdout configuration needs to be updated at runtime.
+ ///
+ /// The new array of holdouts to use
+ public void UpdateHoldoutMapping(Holdout[] newHoldouts)
+ {
+ _allHoldouts = newHoldouts?.ToList() ?? new List();
+ UpdateHoldoutMapping();
+ }
+ }
+}