diff --git a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
index a4495471..303a742e 100644
--- a/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
+++ b/OptimizelySDK.Net35/OptimizelySDK.Net35.csproj
@@ -88,6 +88,12 @@
Entity\Experiment.cs
+
+ Entity\Holdout.cs
+
+
+ Entity\ExperimentCore.cs
+
Entity\FeatureDecision.cs
@@ -215,6 +221,9 @@
Bucketing\ExperimentUtils
+
+ Utils\HoldoutConfig.cs
+
Bucketing\UserProfileUtil
diff --git a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
index 05785575..3e0a9ea5 100644
--- a/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
+++ b/OptimizelySDK.Net40/OptimizelySDK.Net40.csproj
@@ -90,6 +90,12 @@
Entity\Experiment.cs
+
+ Entity\Holdout.cs
+
+
+ Entity\ExperimentCore.cs
+
Entity\FeatureDecision.cs
@@ -214,6 +220,9 @@
Bucketing\ExperimentUtils
+
+ Utils\HoldoutConfig.cs
+
Bucketing\UserProfileUtil
diff --git a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
index b17f79e7..44240b2a 100644
--- a/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
+++ b/OptimizelySDK.NetStandard16/OptimizelySDK.NetStandard16.csproj
@@ -26,6 +26,8 @@
+
+
@@ -64,6 +66,7 @@
+
diff --git a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
index b7114653..12f9cb55 100644
--- a/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
+++ b/OptimizelySDK.NetStandard20/OptimizelySDK.NetStandard20.csproj
@@ -178,7 +178,12 @@
Entity\Experiment.cs
-
+
+ Entity\Holdout.cs
+
+
+ Entity\ExperimentCore.cs
+
Entity\FeatureDecision.cs
@@ -331,6 +336,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..c17cc088
--- /dev/null
+++ b/OptimizelySDK.Tests/EntityTests/HoldoutTests.cs
@@ -0,0 +1,194 @@
+/*
+ * 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.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"",
+ ""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..b7afcba6 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,110 @@ 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);
+ }
+
+ [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);
+
+ // 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..57593a55
--- /dev/null
+++ b/OptimizelySDK.Tests/UtilsTests/HoldoutConfigTests.cs
@@ -0,0 +1,344 @@
+/*
+ * 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",
+ 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 465b384a..f940ffe6 100644
--- a/OptimizelySDK/Config/DatafileProjectConfig.cs
+++ b/OptimizelySDK/Config/DatafileProjectConfig.cs
@@ -232,6 +232,11 @@ private Dictionary> _VariationIdMap
public Dictionary> FlagVariationMap =>
_FlagVariationMap;
+ ///
+ /// Holdout configuration manager for flag-to-holdout relationships.
+ ///
+ private HoldoutConfig _holdoutConfig;
+
//========================= Interfaces ===========================
///
@@ -286,6 +291,11 @@ private Dictionary> _VariationIdMap
///
public Rollout[] Rollouts { get; set; }
+ ///
+ /// Associative list of Holdouts.
+ ///
+ public Holdout[] Holdouts { get; set; }
+
///
/// Associative list of Integrations.
///
@@ -309,6 +319,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();
@@ -450,6 +461,9 @@ private void Initialize()
}
_FlagVariationMap = flagToVariationsMap;
+
+ // Initialize HoldoutConfig for managing flag-to-holdout relationships
+ _holdoutConfig = new HoldoutConfig(Holdouts ?? new Holdout[0]);
}
///
@@ -767,6 +781,16 @@ 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)
+ {
+ return _holdoutConfig.GetHoldout(holdoutId);
+ }
+
///
/// Get attribute ID for the provided attribute key
///
@@ -828,6 +852,15 @@ public string ToDatafile()
}
///
+ /// 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];
+ }
/// Returns the datafile corresponding to ProjectConfig
///
/// the datafile string corresponding to ProjectConfig
diff --git a/OptimizelySDK/Entity/Experiment.cs b/OptimizelySDK/Entity/Experiment.cs
index e1eee5f2..dd25f68c 100644
--- a/OptimizelySDK/Entity/Experiment.cs
+++ b/OptimizelySDK/Entity/Experiment.cs
@@ -15,38 +15,22 @@
*/
using System.Collections.Generic;
-using Newtonsoft.Json;
-using Newtonsoft.Json.Linq;
-using OptimizelySDK.AudienceConditions;
-using OptimizelySDK.Utils;
namespace OptimizelySDK.Entity
{
- public class Experiment : IdKeyEntity
+ public class Experiment : ExperimentCore
{
- private const string STATUS_RUNNING = "Running";
-
private const string MUTEX_GROUP_POLICY = "random";
- ///
- /// Experiment Status
- ///
- public string Status { get; set; }
-
- ///
- /// Layer ID for the experiment
- ///
- public string LayerId { get; set; }
-
///
/// Group ID for the experiment
///
public string GroupId { get; set; }
///
- /// Variations for the experiment
+ /// Layer ID for the experiment
///
- public Variation[] Variations { get; set; }
+ public string LayerId { get; set; }
///
/// ForcedVariations for the experiment
@@ -63,203 +47,6 @@ public class Experiment : IdKeyEntity
///
public string GroupPolicy { get; set; }
- ///
- /// ID(s) of audience(s) the experiment is targeted to
- ///
- public string[] AudienceIds { get; set; }
-
- private ICondition _audienceIdsList = null;
-
- ///
- /// De-serialized audience conditions
- ///
- 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 = (string)audienceId });
- }
-
- _audienceIdsList = new OrCondition() { Conditions = conditions.ToArray() };
- }
-
- return _audienceIdsList;
- }
- }
-
- private string _audienceIdsString = null;
-
- ///
- /// Stringified audience conditions
- ///
- public string AudienceIdsString
- {
- get
- {
- if (AudienceIds == null)
- {
- return null;
- }
-
- if (_audienceIdsString == null)
- {
- _audienceIdsString = JsonConvert.SerializeObject(AudienceIds, Formatting.None);
- }
-
- return _audienceIdsString;
- }
- }
-
- ///
- /// Traffic allocation of variations in the experiment
- ///
- public TrafficAllocation[] TrafficAllocation { get; set; }
-
- ///
- /// Audience Conditions
- ///
- public object AudienceConditions { get; set; }
-
- 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;
- }
- }
-
- private bool isGenerateKeyMapCalled = false;
-
- private Dictionary _VariationKeyToVariationMap;
-
- public Dictionary VariationKeyToVariationMap
- {
- get
- {
- if (!isGenerateKeyMapCalled)
- {
- GenerateVariationKeyMap();
- }
-
- return _VariationKeyToVariationMap;
- }
- }
-
- private Dictionary _VariationIdToVariationMap;
-
- public Dictionary VariationIdToVariationMap
- {
- get
- {
- if (!isGenerateKeyMapCalled)
- {
- GenerateVariationKeyMap();
- }
-
- return _VariationIdToVariationMap;
- }
- }
-
- 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;
- }
-
- // Code from PHP, need to build traffic and variations from config
-#if false
- /**
- * @param $variations array Variations in experiment.
- */
- public function setVariations($variations)
- {
- $this->_variations = ConfigParser::generateMap($variations, null, Variation::class);
- }
-
- /**
- * @param $trafficAllocation array Traffic allocation of variations in experiment.
- */
- public function setTrafficAllocation($trafficAllocation)
- {
- $this->_trafficAllocation =
- ConfigParser::generateMap($trafficAllocation, null, TrafficAllocation::class);
- }
-#endif
-
///
/// Determine if experiment is in a mutually exclusive group
///
@@ -281,5 +68,10 @@ public bool IsUserInForcedVariation(string userId)
{
return ForcedVariations != null && ForcedVariations.ContainsKey(userId);
}
+
+ ///
+ /// Determine if experiment is currently activated/running (implementation of abstract property)
+ ///
+ public override bool IsActivated => IsExperimentRunning;
}
}
diff --git a/OptimizelySDK/Entity/ExperimentCore.cs b/OptimizelySDK/Entity/ExperimentCore.cs
new file mode 100644
index 00000000..61dba9d8
--- /dev/null
+++ b/OptimizelySDK/Entity/ExperimentCore.cs
@@ -0,0 +1,276 @@
+/*
+ * 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
+{
+ ///
+ /// Abstract base class containing common properties and behaviors shared between Experiment and Holdout
+ ///
+ public abstract class ExperimentCore : IdKeyEntity
+ {
+ protected const string STATUS_RUNNING = "Running";
+
+ ///
+ /// Status of the experiment/holdout
+ ///
+ public string Status { get; set; }
+
+ ///
+ /// Variations for the experiment/holdout
+ ///
+ public Variation[] Variations { get; set; }
+
+ ///
+ /// Traffic allocation of variations in the experiment/holdout
+ ///
+ public TrafficAllocation[] TrafficAllocation { get; set; }
+
+ ///
+ /// ID(s) of audience(s) the experiment/holdout is targeted to
+ ///
+ public string[] AudienceIds { get; set; }
+
+ ///
+ /// Audience Conditions
+ ///
+ public object AudienceConditions { get; set; }
+
+ #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)
+ {
+ _audienceConditionsString = 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
+
+ #region Variation Helper Methods
+
+ ///
+ /// Get variation by ID
+ ///
+ /// Variation ID to search for
+ /// Variation with the specified ID, or null if not found
+ public virtual Variation GetVariation(string id)
+ {
+ if (Variations == null || string.IsNullOrEmpty(id))
+ {
+ return null;
+ }
+
+ return Variations.FirstOrDefault(v => v.Id == id);
+ }
+
+ ///
+ /// Get variation by key
+ ///
+ /// Variation key to search for
+ /// Variation with the specified key, or null if not found
+ public virtual Variation GetVariationByKey(string key)
+ {
+ if (Variations == null || string.IsNullOrEmpty(key))
+ {
+ return null;
+ }
+
+ return Variations.FirstOrDefault(v => v.Key == key);
+ }
+
+ #endregion
+
+ ///
+ /// Determine if experiment/holdout is currently activated/running
+ ///
+ public abstract bool IsActivated { get; }
+ }
+}
diff --git a/OptimizelySDK/Entity/Holdout.cs b/OptimizelySDK/Entity/Holdout.cs
new file mode 100644
index 00000000..17f4c2bd
--- /dev/null
+++ b/OptimizelySDK/Entity/Holdout.cs
@@ -0,0 +1,54 @@
+/*
+ * 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;
+
+namespace OptimizelySDK.Entity
+{
+ ///
+ /// Represents a holdout in an Optimizely project
+ ///
+ public class Holdout : ExperimentCore
+ {
+ ///
+ /// Holdout status enumeration
+ ///
+ public enum HoldoutStatus
+ {
+ Draft,
+ Running,
+ Concluded,
+ Archived
+ }
+
+ ///
+ /// 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];
+
+ ///
+ /// Determine if holdout is currently activated/running
+ ///
+ public override bool IsActivated =>
+ !string.IsNullOrEmpty(Status) && Status == STATUS_RUNNING;
+
+ }
+}
diff --git a/OptimizelySDK/Exceptions/OptimizelyException.cs b/OptimizelySDK/Exceptions/OptimizelyException.cs
index ad9cf4d5..ba150b2d 100644
--- a/OptimizelySDK/Exceptions/OptimizelyException.cs
+++ b/OptimizelySDK/Exceptions/OptimizelyException.cs
@@ -102,4 +102,10 @@ public class ParseException : OptimizelyException
public ParseException(string message)
: base(message) { }
}
+ public class InvalidHoldoutException : OptimizelyException
+ {
+ public InvalidHoldoutException(string message)
+ : base(message) { }
+ }
}
+
diff --git a/OptimizelySDK/OptimizelySDK.csproj b/OptimizelySDK/OptimizelySDK.csproj
index 5f041ac1..14201b4e 100644
--- a/OptimizelySDK/OptimizelySDK.csproj
+++ b/OptimizelySDK/OptimizelySDK.csproj
@@ -84,6 +84,8 @@
+
+
@@ -171,6 +173,7 @@
+
diff --git a/OptimizelySDK/ProjectConfig.cs b/OptimizelySDK/ProjectConfig.cs
index 8aab34f7..6a2b5259 100644
--- a/OptimizelySDK/ProjectConfig.cs
+++ b/OptimizelySDK/ProjectConfig.cs
@@ -175,6 +175,11 @@ public interface ProjectConfig
///
Rollout[] Rollouts { get; set; }
+ ///
+ /// Associative list of Holdouts.
+ ///
+ Holdout[] Holdouts { get; set; }
+
///
/// Associative list of Integrations.
///
@@ -308,6 +313,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..6b717af2
--- /dev/null
+++ b/OptimizelySDK/Utils/HoldoutConfig.cs
@@ -0,0 +1,193 @@
+/*
+ * 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)
+ {
+ if (string.IsNullOrEmpty(holdoutId))
+ {
+ return null;
+ }
+
+ _holdoutIdMap.TryGetValue(holdoutId, out var holdout);
+
+ return holdout;
+ }
+
+ ///
+ /// 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();
+ }
+ }
+}