Skip to content

Commit 0e57381

Browse files
authored
[FSSDK-11133] Add cmab to project config (#399)
* add cmab to project config * reformat * add attribute key/id mapping to config * add traffic allocation inside cmab * add test case to test mapCmab * change cmab traffic allocation type to int * add nil test
1 parent fc25850 commit 0e57381

File tree

6 files changed

+289
-20
lines changed

6 files changed

+289
-20
lines changed

pkg/config/datafileprojectconfig/config.go

+31-12
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/****************************************************************************
2-
* Copyright 2019-2022, Optimizely, Inc. and contributors *
2+
* Copyright 2019-2025, Optimizely, Inc. and contributors *
33
* *
44
* Licensed under the Apache License, Version 2.0 (the "License"); *
55
* you may not use this file except in compliance with the License. *
@@ -41,8 +41,10 @@ type DatafileProjectConfig struct {
4141
experimentKeyToIDMap map[string]string
4242
audienceMap map[string]entities.Audience
4343
attributeMap map[string]entities.Attribute
44+
attributeKeyMap map[string]entities.Attribute
4445
eventMap map[string]entities.Event
4546
attributeKeyToIDMap map[string]string
47+
attributeIDToKeyMap map[string]string
4648
experimentMap map[string]entities.Experiment
4749
featureMap map[string]entities.Feature
4850
groupMap map[string]entities.Group
@@ -107,6 +109,24 @@ func (c DatafileProjectConfig) GetAttributeID(key string) string {
107109
return c.attributeKeyToIDMap[key]
108110
}
109111

112+
// GetAttributeByKey returns the attribute with the given key
113+
func (c DatafileProjectConfig) GetAttributeByKey(key string) (entities.Attribute, error) {
114+
if attribute, ok := c.attributeKeyMap[key]; ok {
115+
return attribute, nil
116+
}
117+
118+
return entities.Attribute{}, fmt.Errorf(`attribute with key "%s" not found`, key)
119+
}
120+
121+
// GetAttributeKeyByID returns the attribute key for the given ID
122+
func (c DatafileProjectConfig) GetAttributeKeyByID(id string) (string, error) {
123+
if key, ok := c.attributeIDToKeyMap[id]; ok {
124+
return key, nil
125+
}
126+
127+
return "", fmt.Errorf(`attribute with ID "%s" not found`, id)
128+
}
129+
110130
// GetBotFiltering returns botFiltering
111131
func (c DatafileProjectConfig) GetBotFiltering() bool {
112132
return c.botFiltering
@@ -163,17 +183,6 @@ func (c DatafileProjectConfig) GetVariableByKey(featureKey, variableKey string)
163183
return variable, err
164184
}
165185

166-
// GetAttributeByKey returns the attribute with the given key
167-
func (c DatafileProjectConfig) GetAttributeByKey(key string) (entities.Attribute, error) {
168-
if attributeID, ok := c.attributeKeyToIDMap[key]; ok {
169-
if attribute, ok := c.attributeMap[attributeID]; ok {
170-
return attribute, nil
171-
}
172-
}
173-
174-
return entities.Attribute{}, fmt.Errorf(`attribute with key "%s" not found`, key)
175-
}
176-
177186
// GetFeatureList returns an array of all the features
178187
func (c DatafileProjectConfig) GetFeatureList() (featureList []entities.Feature) {
179188
for _, feature := range c.featureMap {
@@ -300,6 +309,14 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP
300309
audienceMap, audienceSegmentList := mappers.MapAudiences(append(datafile.TypedAudiences, datafile.Audiences...))
301310
flagVariationsMap := mappers.MapFlagVariations(featureMap)
302311

312+
attributeKeyMap := make(map[string]entities.Attribute)
313+
attributeIDToKeyMap := make(map[string]string)
314+
315+
for id, attribute := range attributeMap {
316+
attributeIDToKeyMap[id] = attribute.Key
317+
attributeKeyMap[attribute.Key] = attribute
318+
}
319+
303320
config := &DatafileProjectConfig{
304321
hostForODP: hostForODP,
305322
publicKeyForODP: publicKeyForODP,
@@ -325,6 +342,8 @@ func NewDatafileProjectConfig(jsonDatafile []byte, logger logging.OptimizelyLogP
325342
rolloutMap: rolloutMap,
326343
sendFlagDecisions: datafile.SendFlagDecisions,
327344
flagVariationsMap: flagVariationsMap,
345+
attributeKeyMap: attributeKeyMap,
346+
attributeIDToKeyMap: attributeIDToKeyMap,
328347
}
329348

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

pkg/config/datafileprojectconfig/config_test.go

+136-4
Original file line numberDiff line numberDiff line change
@@ -316,18 +316,24 @@ func TestGetVariableByKeyWithMissingVariableError(t *testing.T) {
316316
}
317317

318318
func TestGetAttributeByKey(t *testing.T) {
319-
id := "id"
320319
key := "key"
321-
attributeKeyToIDMap := make(map[string]string)
322-
attributeKeyToIDMap[key] = id
323-
324320
attribute := entities.Attribute{
325321
Key: key,
326322
}
323+
324+
// The old and new mappings to ensure backward compatibility
325+
attributeKeyMap := make(map[string]entities.Attribute)
326+
attributeKeyMap[key] = attribute
327+
328+
id := "id"
329+
attributeKeyToIDMap := make(map[string]string)
330+
attributeKeyToIDMap[key] = id
331+
327332
attributeMap := make(map[string]entities.Attribute)
328333
attributeMap[id] = attribute
329334

330335
config := &DatafileProjectConfig{
336+
attributeKeyMap: attributeKeyMap,
331337
attributeKeyToIDMap: attributeKeyToIDMap,
332338
attributeMap: attributeMap,
333339
}
@@ -568,3 +574,129 @@ func TestGetFlagVariationsMap(t *testing.T) {
568574
assert.NotNil(t, flagVariationsMap["feature_3"])
569575
assert.Len(t, flagVariationsMap["feature_3"], 0)
570576
}
577+
578+
func TestCmabExperiments(t *testing.T) {
579+
// Load the decide-test-datafile.json
580+
absPath, _ := filepath.Abs("../../../test-data/decide-test-datafile.json")
581+
datafile, err := os.ReadFile(absPath)
582+
assert.NoError(t, err)
583+
584+
// Parse the datafile to modify it
585+
var datafileJSON map[string]interface{}
586+
err = json.Unmarshal(datafile, &datafileJSON)
587+
assert.NoError(t, err)
588+
589+
// Add CMAB to the first experiment with traffic allocation as an integer
590+
experiments := datafileJSON["experiments"].([]interface{})
591+
exp0 := experiments[0].(map[string]interface{})
592+
exp0["cmab"] = map[string]interface{}{
593+
"attributes": []string{"808797688", "808797689"},
594+
"trafficAllocation": 5000, // Changed from array to integer
595+
}
596+
597+
// Convert back to JSON
598+
modifiedDatafile, err := json.Marshal(datafileJSON)
599+
assert.NoError(t, err)
600+
601+
// Create project config from modified datafile
602+
config, err := NewDatafileProjectConfig(modifiedDatafile, logging.GetLogger("", "DatafileProjectConfig"))
603+
assert.NoError(t, err)
604+
605+
// Get the experiment key from the datafile
606+
exp0Key := exp0["key"].(string)
607+
608+
// Test that Cmab fields are correctly mapped for experiment 0
609+
experiment0, err := config.GetExperimentByKey(exp0Key)
610+
assert.NoError(t, err)
611+
assert.NotNil(t, experiment0.Cmab)
612+
if experiment0.Cmab != nil {
613+
// Test attribute IDs
614+
assert.Equal(t, 2, len(experiment0.Cmab.AttributeIds))
615+
assert.Contains(t, experiment0.Cmab.AttributeIds, "808797688")
616+
assert.Contains(t, experiment0.Cmab.AttributeIds, "808797689")
617+
618+
// Test traffic allocation as integer
619+
assert.Equal(t, 5000, experiment0.Cmab.TrafficAllocation)
620+
}
621+
}
622+
623+
func TestCmabExperimentsNil(t *testing.T) {
624+
// Load the decide-test-datafile.json (which doesn't have CMAB by default)
625+
absPath, _ := filepath.Abs("../../../test-data/decide-test-datafile.json")
626+
datafile, err := os.ReadFile(absPath)
627+
assert.NoError(t, err)
628+
629+
// Create project config from the original datafile
630+
config, err := NewDatafileProjectConfig(datafile, logging.GetLogger("", "DatafileProjectConfig"))
631+
assert.NoError(t, err)
632+
633+
// Parse the datafile to get experiment keys
634+
var datafileJSON map[string]interface{}
635+
err = json.Unmarshal(datafile, &datafileJSON)
636+
assert.NoError(t, err)
637+
638+
experiments := datafileJSON["experiments"].([]interface{})
639+
exp0 := experiments[0].(map[string]interface{})
640+
exp0Key := exp0["key"].(string)
641+
642+
// Test that Cmab field is nil for experiment 0
643+
experiment0, err := config.GetExperimentByKey(exp0Key)
644+
assert.NoError(t, err)
645+
assert.Nil(t, experiment0.Cmab, "CMAB field should be nil when not present in datafile")
646+
647+
// Test another experiment if available
648+
if len(experiments) > 1 {
649+
exp1 := experiments[1].(map[string]interface{})
650+
exp1Key := exp1["key"].(string)
651+
652+
experiment1, err := config.GetExperimentByKey(exp1Key)
653+
assert.NoError(t, err)
654+
assert.Nil(t, experiment1.Cmab, "CMAB field should be nil when not present in datafile")
655+
}
656+
}
657+
658+
func TestGetAttributeKeyByID(t *testing.T) {
659+
// Setup
660+
id := "id"
661+
key := "key"
662+
attributeIDToKeyMap := make(map[string]string)
663+
attributeIDToKeyMap[id] = key
664+
665+
config := &DatafileProjectConfig{
666+
attributeIDToKeyMap: attributeIDToKeyMap,
667+
}
668+
669+
// Test successful case
670+
actual, err := config.GetAttributeKeyByID(id)
671+
assert.Nil(t, err)
672+
assert.Equal(t, key, actual)
673+
}
674+
675+
func TestGetAttributeKeyByIDWithMissingIDError(t *testing.T) {
676+
// Setup
677+
config := &DatafileProjectConfig{}
678+
679+
// Test error case
680+
_, err := config.GetAttributeKeyByID("id")
681+
if assert.Error(t, err) {
682+
assert.Equal(t, fmt.Errorf(`attribute with ID "id" not found`), err)
683+
}
684+
}
685+
686+
func TestGetAttributeByKeyWithDirectMapping(t *testing.T) {
687+
key := "key"
688+
attribute := entities.Attribute{
689+
Key: key,
690+
}
691+
692+
attributeKeyMap := make(map[string]entities.Attribute)
693+
attributeKeyMap[key] = attribute
694+
695+
config := &DatafileProjectConfig{
696+
attributeKeyMap: attributeKeyMap,
697+
}
698+
699+
actual, err := config.GetAttributeByKey(key)
700+
assert.Nil(t, err)
701+
assert.Equal(t, attribute, actual)
702+
}

pkg/config/datafileprojectconfig/entities/entities.go

+10-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/****************************************************************************
2-
* Copyright 2019,2021-2022, Optimizely, Inc. and contributors *
2+
* Copyright 2019,2021-2025, Optimizely, Inc. and contributors *
33
* *
44
* Licensed under the Apache License, Version 2.0 (the "License"); *
55
* you may not use this file except in compliance with the License. *
@@ -32,6 +32,14 @@ type Attribute struct {
3232
Key string `json:"key"`
3333
}
3434

35+
// Cmab represents the Contextual Multi-Armed Bandit configuration for an experiment.
36+
// It contains a list of attribute IDs that are used for the CMAB algorithm and
37+
// traffic allocation settings for the CMAB implementation.
38+
type Cmab struct {
39+
AttributeIds []string `json:"attributes"`
40+
TrafficAllocation int `json:"trafficAllocation"`
41+
}
42+
3543
// Experiment represents an Experiment object from the Optimizely datafile
3644
type Experiment struct {
3745
ID string `json:"id"`
@@ -43,6 +51,7 @@ type Experiment struct {
4351
AudienceIds []string `json:"audienceIds"`
4452
ForcedVariations map[string]string `json:"forcedVariations"`
4553
AudienceConditions interface{} `json:"audienceConditions"`
54+
Cmab *Cmab `json:"cmab,omitempty"` // is optional
4655
}
4756

4857
// Group represents an Group object from the Optimizely datafile

pkg/config/datafileprojectconfig/mappers/experiment.go

+14-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/****************************************************************************
2-
* Copyright 2019,2021, Optimizely, Inc. and contributors *
2+
* Copyright 2019,2021-2025, Optimizely, Inc. and contributors *
33
* *
44
* Licensed under the Apache License, Version 2.0 (the "License"); *
55
* you may not use this file except in compliance with the License. *
@@ -91,6 +91,7 @@ func mapExperiment(rawExperiment datafileEntities.Experiment) entities.Experimen
9191
AudienceConditionTree: audienceConditionTree,
9292
Whitelist: rawExperiment.ForcedVariations,
9393
IsFeatureExperiment: false,
94+
Cmab: mapCmab(rawExperiment.Cmab),
9495
}
9596

9697
for _, variation := range rawExperiment.Variations {
@@ -113,3 +114,15 @@ func MergeExperiments(rawExperiments []datafileEntities.Experiment, rawGroups []
113114
}
114115
return mergedExperiments
115116
}
117+
118+
func mapCmab(rawCmab *datafileEntities.Cmab) *entities.Cmab {
119+
// handle nil case because cmab is optional and can be nil
120+
if rawCmab == nil {
121+
return nil
122+
}
123+
124+
return &entities.Cmab{
125+
AttributeIds: rawCmab.AttributeIds,
126+
TrafficAllocation: rawCmab.TrafficAllocation,
127+
}
128+
}

0 commit comments

Comments
 (0)