Skip to content

Commit 0b81763

Browse files
committed
add holdout model
1 parent a1e58e8 commit 0b81763

File tree

6 files changed

+422
-22
lines changed

6 files changed

+422
-22
lines changed

pkg/config/datafileprojectconfig/config.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@ type DatafileProjectConfig struct {
6060
region string
6161

6262
flagVariationsMap map[string][]entities.Variation
63+
holdoutMaps mappers.HoldoutMaps
6364
}
6465

6566
// GetDatafile returns a string representation of the environment's datafile
@@ -324,6 +325,13 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP
324325
audienceMap, audienceSegmentList := mappers.MapAudiences(append(datafile.TypedAudiences, datafile.Audiences...))
325326
flagVariationsMap := mappers.MapFlagVariations(featureMap)
326327

328+
// Process holdouts and compute holdout IDs for each feature flag
329+
holdoutMaps := mappers.MapHoldouts(datafile.Holdouts, audienceMap)
330+
for featureKey, feature := range featureMap {
331+
feature.HoldoutIDs = mappers.GetHoldoutsForFlag(feature.ID, holdoutMaps)
332+
featureMap[featureKey] = feature
333+
}
334+
327335
attributeKeyMap := make(map[string]entities.Attribute)
328336
attributeIDToKeyMap := make(map[string]string)
329337

@@ -365,6 +373,7 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP
365373
attributeKeyMap: attributeKeyMap,
366374
attributeIDToKeyMap: attributeIDToKeyMap,
367375
region: region,
376+
holdoutMaps: holdoutMaps,
368377
}
369378

370379
logger.Info("Datafile is valid.")

pkg/config/datafileprojectconfig/entities/entities.go

Lines changed: 36 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -40,18 +40,24 @@ type Cmab struct {
4040
TrafficAllocation int `json:"trafficAllocation"`
4141
}
4242

43-
// Experiment represents an Experiment object from the Optimizely datafile
44-
type Experiment struct {
43+
// ExperimentCore contains the shared properties between Experiment and Holdout
44+
// This represents the common bucketing and targeting logic
45+
type ExperimentCore struct {
4546
ID string `json:"id"`
4647
Key string `json:"key"`
47-
LayerID string `json:"layerId"`
48-
Status string `json:"status"`
4948
Variations []Variation `json:"variations"`
5049
TrafficAllocation []TrafficAllocation `json:"trafficAllocation"`
5150
AudienceIds []string `json:"audienceIds"`
52-
ForcedVariations map[string]string `json:"forcedVariations"`
5351
AudienceConditions interface{} `json:"audienceConditions"`
54-
Cmab *Cmab `json:"cmab,omitempty"` // is optional
52+
}
53+
54+
// Experiment represents an Experiment object from the Optimizely datafile
55+
type Experiment struct {
56+
ExperimentCore
57+
LayerID string `json:"layerId"`
58+
Status string `json:"status"`
59+
ForcedVariations map[string]string `json:"forcedVariations"`
60+
Cmab *Cmab `json:"cmab,omitempty"` // is optional
5561
}
5662

5763
// Group represents an Group object from the Optimizely datafile
@@ -62,6 +68,29 @@ type Group struct {
6268
Experiments []Experiment `json:"experiments"`
6369
}
6470

71+
// HoldoutStatus represents the status of a holdout
72+
type HoldoutStatus string
73+
74+
const (
75+
// HoldoutStatusDraft - the holdout status is draft
76+
HoldoutStatusDraft HoldoutStatus = "Draft"
77+
// HoldoutStatusRunning - the holdout status is running
78+
HoldoutStatusRunning HoldoutStatus = "Running"
79+
// HoldoutStatusConcluded - the holdout status is concluded
80+
HoldoutStatusConcluded HoldoutStatus = "Concluded"
81+
// HoldoutStatusArchived - the holdout status is archived
82+
HoldoutStatusArchived HoldoutStatus = "Archived"
83+
)
84+
85+
// Holdout represents a Holdout object from the Optimizely datafile
86+
// Holdouts share core properties with Experiments through ExperimentCore embedding
87+
type Holdout struct {
88+
ExperimentCore
89+
Status HoldoutStatus `json:"status"`
90+
IncludedFlags []string `json:"includedFlags"`
91+
ExcludedFlags []string `json:"excludedFlags"`
92+
}
93+
6594
// FeatureFlag represents a FeatureFlag object from the Optimizely datafile
6695
type FeatureFlag struct {
6796
ID string `json:"id"`
@@ -132,6 +161,7 @@ type Datafile struct {
132161
Integrations []Integration `json:"integrations"`
133162
TypedAudiences []Audience `json:"typedAudiences"`
134163
Variables []string `json:"variables"`
164+
Holdouts []Holdout `json:"holdouts"`
135165
AccountID string `json:"accountId"`
136166
ProjectID string `json:"projectId"`
137167
Revision string `json:"revision"`

pkg/config/datafileprojectconfig/mappers/experiment_test.go

Lines changed: 28 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -125,10 +125,12 @@ func TestMapExperiments(t *testing.T) {
125125
func TestMapExperimentsWithStringAudienceCondition(t *testing.T) {
126126

127127
rawExperiment := datafileEntities.Experiment{
128-
ID: "11111",
129-
AudienceIds: []string{"31111"},
130-
Key: "test_experiment_11111",
131-
AudienceConditions: "31111",
128+
ExperimentCore: datafileEntities.ExperimentCore{
129+
ID: "11111",
130+
AudienceIds: []string{"31111"},
131+
Key: "test_experiment_11111",
132+
AudienceConditions: "31111",
133+
},
132134
}
133135

134136
rawExperiments := []datafileEntities.Experiment{rawExperiment}
@@ -167,7 +169,9 @@ func TestMapExperimentsWithStringAudienceCondition(t *testing.T) {
167169
func TestMergeExperiments(t *testing.T) {
168170

169171
rawExperiment := datafileEntities.Experiment{
170-
ID: "11111",
172+
ExperimentCore: datafileEntities.ExperimentCore{
173+
ID: "11111",
174+
},
171175
}
172176
rawGroup := datafileEntities.Group{
173177
Policy: "random",
@@ -184,7 +188,9 @@ func TestMergeExperiments(t *testing.T) {
184188
},
185189
Experiments: []datafileEntities.Experiment{
186190
{
187-
ID: "11112",
191+
ExperimentCore: datafileEntities.ExperimentCore{
192+
ID: "11112",
193+
},
188194
},
189195
},
190196
}
@@ -195,10 +201,14 @@ func TestMergeExperiments(t *testing.T) {
195201

196202
expectedExperiments := []datafileEntities.Experiment{
197203
{
198-
ID: "11111",
204+
ExperimentCore: datafileEntities.ExperimentCore{
205+
ID: "11111",
206+
},
199207
},
200208
{
201-
ID: "11112",
209+
ExperimentCore: datafileEntities.ExperimentCore{
210+
ID: "11112",
211+
},
202212
},
203213
}
204214

@@ -302,15 +312,17 @@ func TestMapCmab(t *testing.T) {
302312
func TestMapExperimentWithCmab(t *testing.T) {
303313
// Create a raw experiment with CMAB configuration
304314
rawExperiment := datafileEntities.Experiment{
305-
ID: "exp1",
306-
Key: "experiment_1",
307-
LayerID: "layer1",
308-
Variations: []datafileEntities.Variation{
309-
{ID: "var1", Key: "variation_1"},
310-
},
311-
TrafficAllocation: []datafileEntities.TrafficAllocation{
312-
{EntityID: "var1", EndOfRange: 10000},
315+
ExperimentCore: datafileEntities.ExperimentCore{
316+
ID: "exp1",
317+
Key: "experiment_1",
318+
Variations: []datafileEntities.Variation{
319+
{ID: "var1", Key: "variation_1"},
320+
},
321+
TrafficAllocation: []datafileEntities.TrafficAllocation{
322+
{EntityID: "var1", EndOfRange: 10000},
323+
},
313324
},
325+
LayerID: "layer1",
314326
Cmab: &datafileEntities.Cmab{
315327
AttributeIds: []string{"attr1", "attr2"},
316328
TrafficAllocation: 5000, // Changed from array to int
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
/****************************************************************************
2+
* Copyright 2025, Optimizely, Inc. and contributors *
3+
* *
4+
* Licensed under the Apache License, Version 2.0 (the "License"); *
5+
* you may not use this file except in compliance with the License. *
6+
* You may obtain a copy of the License at *
7+
* *
8+
* http://www.apache.org/licenses/LICENSE-2.0 *
9+
* *
10+
* Unless required by applicable law or agreed to in writing, software *
11+
* distributed under the License is distributed on an "AS IS" BASIS, *
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. *
13+
* See the License for the specific language governing permissions and *
14+
* limitations under the License. *
15+
***************************************************************************/
16+
17+
// Package mappers ...
18+
package mappers
19+
20+
import (
21+
datafileEntities "github.com/optimizely/go-sdk/v2/pkg/config/datafileprojectconfig/entities"
22+
"github.com/optimizely/go-sdk/v2/pkg/entities"
23+
)
24+
25+
// HoldoutMaps contains the different holdout mappings for efficient lookup
26+
type HoldoutMaps struct {
27+
HoldoutIDMap map[string]entities.Holdout // Map holdout ID to holdout
28+
GlobalHoldouts []entities.Holdout // Holdouts with no specific flag inclusion
29+
IncludedHoldouts map[string][]entities.Holdout // Map flag ID to holdouts that include it
30+
ExcludedHoldouts map[string][]entities.Holdout // Map flag ID to holdouts that exclude it
31+
FlagHoldoutsMap map[string][]string // Cached map of flag ID to holdout IDs
32+
}
33+
34+
// MapHoldouts maps the raw datafile holdout entities to SDK Holdout entities
35+
// and creates the necessary mappings for efficient holdout lookup
36+
func MapHoldouts(holdouts []datafileEntities.Holdout, audienceMap map[string]entities.Audience) HoldoutMaps {
37+
holdoutMaps := HoldoutMaps{
38+
HoldoutIDMap: make(map[string]entities.Holdout),
39+
GlobalHoldouts: []entities.Holdout{},
40+
IncludedHoldouts: make(map[string][]entities.Holdout),
41+
ExcludedHoldouts: make(map[string][]entities.Holdout),
42+
FlagHoldoutsMap: make(map[string][]string),
43+
}
44+
45+
for _, datafileHoldout := range holdouts {
46+
// Map variations similar to experiments
47+
variationMap := make(map[string]entities.Variation)
48+
variationKeyToIDMap := make(map[string]string)
49+
50+
for _, variation := range datafileHoldout.Variations {
51+
variableMap := make(map[string]entities.VariationVariable)
52+
for _, variable := range variation.Variables {
53+
variableMap[variable.ID] = entities.VariationVariable{
54+
ID: variable.ID,
55+
Value: variable.Value,
56+
}
57+
}
58+
59+
v := entities.Variation{
60+
ID: variation.ID,
61+
Key: variation.Key,
62+
FeatureEnabled: variation.FeatureEnabled,
63+
Variables: variableMap,
64+
}
65+
variationMap[variation.ID] = v
66+
variationKeyToIDMap[variation.Key] = variation.ID
67+
}
68+
69+
// Map traffic allocation
70+
trafficAllocation := []entities.Range{}
71+
for _, allocation := range datafileHoldout.TrafficAllocation {
72+
trafficAllocation = append(trafficAllocation, entities.Range{
73+
EntityID: allocation.EntityID,
74+
EndOfRange: allocation.EndOfRange,
75+
})
76+
}
77+
78+
// Build audience condition tree
79+
var audienceConditionTree *entities.TreeNode
80+
if datafileHoldout.AudienceConditions != nil {
81+
audienceConditionTree, _, _ = buildConditionTree(datafileHoldout.AudienceConditions)
82+
} else if len(datafileHoldout.AudienceIds) > 0 {
83+
// Build from audience IDs similar to experiments
84+
audienceConditionTree, _ = buildAudienceConditionTree(datafileHoldout.AudienceIds)
85+
}
86+
87+
// Convert status string to HoldoutStatus type
88+
status := entities.HoldoutStatus(datafileHoldout.Status)
89+
90+
// Create the runtime holdout entity
91+
holdout := entities.Holdout{
92+
ID: datafileHoldout.ID,
93+
Key: datafileHoldout.Key,
94+
Status: status,
95+
Variations: variationMap,
96+
VariationKeyToIDMap: variationKeyToIDMap,
97+
TrafficAllocation: trafficAllocation,
98+
AudienceIds: datafileHoldout.AudienceIds,
99+
AudienceConditions: datafileHoldout.AudienceConditions,
100+
AudienceConditionTree: audienceConditionTree,
101+
IncludedFlags: datafileHoldout.IncludedFlags,
102+
ExcludedFlags: datafileHoldout.ExcludedFlags,
103+
}
104+
105+
// Add to ID map
106+
holdoutMaps.HoldoutIDMap[holdout.ID] = holdout
107+
108+
// Categorize holdouts based on flag targeting
109+
if len(datafileHoldout.IncludedFlags) == 0 {
110+
// This is a global holdout (applies to all flags unless excluded)
111+
holdoutMaps.GlobalHoldouts = append(holdoutMaps.GlobalHoldouts, holdout)
112+
113+
// Add to excluded flags map
114+
for _, flagID := range datafileHoldout.ExcludedFlags {
115+
holdoutMaps.ExcludedHoldouts[flagID] = append(holdoutMaps.ExcludedHoldouts[flagID], holdout)
116+
}
117+
} else {
118+
// This holdout specifically includes certain flags
119+
for _, flagID := range datafileHoldout.IncludedFlags {
120+
holdoutMaps.IncludedHoldouts[flagID] = append(holdoutMaps.IncludedHoldouts[flagID], holdout)
121+
}
122+
}
123+
}
124+
125+
return holdoutMaps
126+
}
127+
128+
// GetHoldoutsForFlag returns the holdout IDs that apply to a specific flag
129+
// This follows the logic from JavaScript SDK: global holdouts (minus excluded) + specifically included
130+
func GetHoldoutsForFlag(flagID string, holdoutMaps HoldoutMaps) []string {
131+
// Check cache first
132+
if cachedHoldoutIDs, exists := holdoutMaps.FlagHoldoutsMap[flagID]; exists {
133+
return cachedHoldoutIDs
134+
}
135+
136+
holdoutIDs := []string{}
137+
138+
// Add global holdouts that don't exclude this flag
139+
for _, holdout := range holdoutMaps.GlobalHoldouts {
140+
isExcluded := false
141+
for _, excludedFlagID := range holdout.ExcludedFlags {
142+
if excludedFlagID == flagID {
143+
isExcluded = true
144+
break
145+
}
146+
}
147+
if !isExcluded {
148+
holdoutIDs = append(holdoutIDs, holdout.ID)
149+
}
150+
}
151+
152+
// Add holdouts that specifically include this flag
153+
if includedHoldouts, exists := holdoutMaps.IncludedHoldouts[flagID]; exists {
154+
for _, holdout := range includedHoldouts {
155+
holdoutIDs = append(holdoutIDs, holdout.ID)
156+
}
157+
}
158+
159+
// Cache the result
160+
holdoutMaps.FlagHoldoutsMap[flagID] = holdoutIDs
161+
162+
return holdoutIDs
163+
}

0 commit comments

Comments
 (0)