Skip to content

Commit 992e144

Browse files
authored
FF-2022 Add bandits (#50)
* refactor(httpclient): return []bytes instead of converting to string * refactor(client): make client hold configurationStore directly With this refactoring, configurationRequestor doesn't need to proxy get configuration requests to configurationStore. This also makes configRequestor/poller optional in the client which simplifies the testing. * FF-2022 Add bandits * FF-2022 Update to support non-bandit flag test case * FF-2022 refactor: banditModelData.evaluate() cannot fail now * refactor: rename ufc -> flags/config * test: skip dynamically-typed test cases
1 parent 864085a commit 992e144

19 files changed

+1001
-166
lines changed

eppoclient/assignmentlogger.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ type IAssignmentLogger interface {
66
LogAssignment(event AssignmentEvent)
77
}
88

9+
// BanditActionLogger is going to be merged into IAssignmentLogger in
10+
// the next major version.
11+
type BanditActionLogger interface {
12+
LogBanditAction(event BanditEvent)
13+
}
14+
15+
// TODO: in the next major release, upgrade Timestamp fields to time.Time.
16+
917
type AssignmentEvent struct {
1018
Experiment string `json:"experiment"`
1119
FeatureFlag string `json:"featureFlag"`
@@ -17,6 +25,21 @@ type AssignmentEvent struct {
1725
MetaData map[string]string `json:"metaData"`
1826
ExtraLogging map[string]string `json:"extraLogging,omitempty"`
1927
}
28+
type BanditEvent struct {
29+
FlagKey string `json:"flagKey"`
30+
BanditKey string `json:"banditKey"`
31+
Subject string `json:"subject"`
32+
Action string `json:"action,omitempty"`
33+
ActionProbability float64 `json:"actionProbability,omitempty"`
34+
OptimalityGap float64 `json:"optimalityGap,omitempty"`
35+
ModelVersion string `json:"modelVersion,omitempty"`
36+
Timestamp string `json:"timestamp"`
37+
SubjectNumericAttributes map[string]float64 `json:"subjectNumericAttributes,omitempty"`
38+
SubjectCategoricalAttributes map[string]string `json:"subjectCategoricalAttributes,omitempty"`
39+
ActionNumericAttributes map[string]float64 `json:"actionNumericAttributes,omitempty"`
40+
ActionCategoricalAttributes map[string]string `json:"actionCategoricalAttributes,omitempty"`
41+
MetaData map[string]string `json:"metaData"`
42+
}
2043

2144
type AssignmentLogger struct {
2245
}
@@ -29,3 +52,8 @@ func (al *AssignmentLogger) LogAssignment(event AssignmentEvent) {
2952
fmt.Println("Assignment Logged")
3053
fmt.Println(event)
3154
}
55+
56+
func (al *AssignmentLogger) LogBanditAction(event BanditEvent) {
57+
fmt.Println("Bandit Action Logged")
58+
fmt.Println(event)
59+
}

eppoclient/banditmodel.go

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
package eppoclient
2+
3+
import "time"
4+
5+
type banditResponse struct {
6+
Bandits map[string]banditConfiguration `json:"bandits"`
7+
UpdatedAt time.Time `json:"updatedAt"`
8+
}
9+
10+
type banditConfiguration struct {
11+
BanditKey string `json:"banditKey"`
12+
ModelName string `json:"modelName"`
13+
ModelVersion string `json:"modelVersion"`
14+
ModelData banditModelData `json:"modelData"`
15+
UpdatedAt time.Time `json:"updatedAt"`
16+
}
17+
18+
type banditModelData struct {
19+
Gamma float64 `json:"gamma"`
20+
DefaultActionScore float64 `json:"defaultActionScore"`
21+
ActionProbabilityFloor float64 `json:"actionProbabilityFloor"`
22+
Coefficients map[string]banditCoefficients `json:"coefficients"`
23+
}
24+
25+
type banditCoefficients struct {
26+
ActionKey string `json:"actionKey"`
27+
Intercept float64 `json:"intercept"`
28+
SubjectNumericCoefficients []banditNumericAttributeCoefficient `json:"subjectNumericCoefficients"`
29+
SubjectCategoricalCoefficients []banditCategoricalAttributeCoefficient `json:"subjectCategoricalCoefficients"`
30+
ActionNumericCoefficients []banditNumericAttributeCoefficient `json:"actionNumericCoefficients"`
31+
ActionCategoricalCoefficients []banditCategoricalAttributeCoefficient `json:"actionCategoricalCoefficients"`
32+
}
33+
34+
type banditCategoricalAttributeCoefficient struct {
35+
AttributeKey string `json:"attributeKey"`
36+
MissingValueCoefficient float64 `json:"missingValueCoefficient"`
37+
ValueCoefficients map[string]float64 `json:"valueCoefficients"`
38+
}
39+
40+
type banditNumericAttributeCoefficient struct {
41+
AttributeKey string `json:"attributeKey"`
42+
Coefficient float64 `json:"coefficient"`
43+
MissingValueCoefficient float64 `json:"missingValueCoefficient"`
44+
}
45+
46+
type banditVariation struct {
47+
Key string `json:"key"`
48+
FlagKey string `json:"flagKey"`
49+
VariationKey string `json:"variationKey"`
50+
VariationValue string `json:"variationValue"`
51+
}

eppoclient/banditmodel_test.go

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
package eppoclient
2+
3+
import (
4+
"encoding/json"
5+
"os"
6+
"testing"
7+
8+
"github.com/stretchr/testify/assert"
9+
)
10+
11+
func Test_banditResponse_parseTestFile(t *testing.T) {
12+
banditResponse := banditResponse{}
13+
14+
file, err := os.Open("test-data/ufc/bandit-models-v1.json")
15+
if err != nil {
16+
t.Fatal(err)
17+
}
18+
defer file.Close()
19+
20+
decoder := json.NewDecoder(file)
21+
err = decoder.Decode(&banditResponse)
22+
if err != nil {
23+
t.Fatal(err)
24+
}
25+
26+
assert.NotEmpty(t, banditResponse.Bandits)
27+
}

eppoclient/bandits_test.go

Lines changed: 135 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,135 @@
1+
package eppoclient
2+
3+
import (
4+
"encoding/json"
5+
"io"
6+
"os"
7+
"path/filepath"
8+
"strings"
9+
"testing"
10+
11+
"github.com/stretchr/testify/assert"
12+
"github.com/stretchr/testify/mock"
13+
)
14+
15+
type banditTest struct {
16+
Flag string
17+
DefaultValue string
18+
Subjects []struct {
19+
SubjectKey string
20+
SubjectAttributes struct {
21+
Numeric map[string]float64 `json:"numericAttributes"`
22+
Categorical map[string]string `json:"categoricalAttributes"`
23+
}
24+
Actions []struct {
25+
ActionKey string
26+
NumericAttributes map[string]float64
27+
CategoricalAttributes map[string]string
28+
}
29+
Assignment BanditResult
30+
}
31+
}
32+
33+
func Test_InferContextAttributes(t *testing.T) {
34+
attributes := Attributes{
35+
"string": "blah",
36+
"int": 42,
37+
"bool": true,
38+
}
39+
contextAttributes := InferContextAttributes(attributes)
40+
41+
expected := ContextAttributes{
42+
Numeric: map[string]float64{
43+
"int": 42.0,
44+
},
45+
Categorical: map[string]string{
46+
"string": "blah",
47+
"bool": "true",
48+
},
49+
}
50+
51+
assert.Equal(t, expected, contextAttributes)
52+
}
53+
54+
func Test_bandits_sdkTestData(t *testing.T) {
55+
flags := readJsonFile[configResponse]("test-data/ufc/bandit-flags-v1.json")
56+
bandits := readJsonFile[banditResponse]("test-data/ufc/bandit-models-v1.json")
57+
configStore := newConfigurationStore(configuration{
58+
flags: flags,
59+
bandits: bandits,
60+
})
61+
logger := new(mockLogger)
62+
logger.Mock.On("LogAssignment", mock.Anything).Return()
63+
logger.Mock.On("LogBanditAction", mock.Anything).Return()
64+
client := newEppoClient(configStore, nil, nil, logger)
65+
66+
tests := readJsonDirectory[banditTest]("test-data/ufc/bandit-tests/")
67+
for file, test := range tests {
68+
t.Run(file, func(t *testing.T) {
69+
for _, subject := range test.Subjects {
70+
t.Run(subject.SubjectKey, func(t *testing.T) {
71+
actions := make(map[string]ContextAttributes)
72+
for _, a := range subject.Actions {
73+
actions[a.ActionKey] = ContextAttributes{
74+
Numeric: a.NumericAttributes,
75+
Categorical: a.CategoricalAttributes,
76+
}
77+
}
78+
79+
result := client.GetBanditAction(
80+
test.Flag,
81+
subject.SubjectKey,
82+
ContextAttributes{
83+
Numeric: subject.SubjectAttributes.Numeric,
84+
Categorical: subject.SubjectAttributes.Categorical,
85+
},
86+
actions,
87+
test.DefaultValue)
88+
89+
assert.Equal(t, subject.Assignment, result)
90+
})
91+
}
92+
})
93+
}
94+
}
95+
96+
func readJsonFile[T any](filePath string) T {
97+
jsonFile, err := os.Open(filePath)
98+
if err != nil {
99+
panic(err)
100+
}
101+
defer jsonFile.Close()
102+
103+
byteValue, err := io.ReadAll(jsonFile)
104+
if err != nil {
105+
panic(err)
106+
}
107+
108+
var target T
109+
err = json.Unmarshal(byteValue, &target)
110+
if err != nil {
111+
panic(err)
112+
}
113+
114+
return target
115+
}
116+
117+
func readJsonDirectory[T any](dirPath string) map[string]T {
118+
results := make(map[string]T)
119+
120+
err := filepath.Walk(dirPath, func(path string, info os.FileInfo, err error) error {
121+
if err != nil {
122+
return err
123+
}
124+
if !info.IsDir() && filepath.Ext(path) == ".json" && !strings.Contains(filepath.Base(path), ".dynamic-typing.") {
125+
results[filepath.Base(path)] = readJsonFile[T](path)
126+
}
127+
return nil
128+
})
129+
130+
if err != nil {
131+
panic(err)
132+
}
133+
134+
return results
135+
}

0 commit comments

Comments
 (0)