Skip to content

Commit d5552e4

Browse files
authored
Bring UFC-based EppoClient into Common (#29)
* Eppo Client with shared UFC tests passing (#23) * tests passing for rule evaluator, flag evaluator, and eppo value * work in progress * shared UFC tests passing * don't check in test data * changes from self-review of PR * apply spotless linter * working on tests * better test logging * use make test for tests * Add bandit support (#25) * bandit test harness ready * add bandit result * set up for dropping in bandit evaluation * bandit deserialization wired up * loading bandit parameters * bandit stuff happening * shared bandit tests passing * bandit logger classes * bandit log test passing * more tests for logger * bandit tests for graceful mode * apply spotless formatting autofix * changes from self-review of PR so far * more changes from self-review of PR * more changes from self-review * make test less fragile * bump version; don't sign local maven * bandit logging errors should be non-fatal * use normalized probability floor * update result before even attempting to log bandit * spotless 🙄 * do the rename (#26) * Remove singleton pattern (#28) * work in progress * remove singleton for base client * linter * expose bandit test harnesses * expose test uilities * changes from self-review of PR * make base client constructor protected
1 parent 4cb7418 commit d5552e4

File tree

87 files changed

+4215
-1684
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

87 files changed

+4215
-1684
lines changed

.github/workflows/lint-test-sdk.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,4 @@ jobs:
3232
gpg-passphrase: ${{ secrets.GPG_PASSPHRASE }}
3333

3434
- name: Run tests
35-
run: ./gradlew check --no-daemon
35+
run: make test

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
src/test/resources/shared
12
.gradle
23
build/
34
!gradle/wrapper/gradle-wrapper.jar

Makefile

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
# Make settings - @see https://tech.davis-hansson.com/p/make/
2+
SHELL := bash
3+
.ONESHELL:
4+
.SHELLFLAGS := -eu -o pipefail -c
5+
.DELETE_ON_ERROR:
6+
MAKEFLAGS += --warn-undefined-variables
7+
MAKEFLAGS += --no-builtin-rules
8+
9+
# Log levels
10+
DEBUG := $(shell printf "\e[2D\e[35m")
11+
INFO := $(shell printf "\e[2D\e[36m🔵 ")
12+
OK := $(shell printf "\e[2D\e[32m🟢 ")
13+
WARN := $(shell printf "\e[2D\e[33m🟡 ")
14+
ERROR := $(shell printf "\e[2D\e[31m🔴 ")
15+
END := $(shell printf "\e[0m")
16+
17+
18+
.PHONY: default
19+
default: help
20+
21+
## help - Print help message.
22+
.PHONY: help
23+
help: Makefile
24+
@echo "usage: make <target>"
25+
@sed -n 's/^##//p' $<
26+
27+
.PHONY: build
28+
build: test-data
29+
./gradlew assemble
30+
31+
## test-data
32+
testDataDir := src/test/resources/shared
33+
tempDir := ${testDataDir}/temp
34+
gitDataDir := ${tempDir}/sdk-test-data
35+
branchName := main
36+
githubRepoLink := https://github.com/Eppo-exp/sdk-test-data.git
37+
.PHONY: test-data
38+
test-data:
39+
rm -rf $(testDataDir)
40+
mkdir -p ${tempDir}
41+
git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir}
42+
cp -r ${gitDataDir}/ufc ${testDataDir}
43+
rm ${testDataDir}/ufc/bandit-tests/*.dynamic-typing.json
44+
rm -rf ${tempDir}
45+
46+
.PHONY: test
47+
test: test-data build
48+
./gradlew check --no-daemon

build.gradle

Lines changed: 30 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,14 @@ plugins {
66
}
77

88
group = 'cloud.eppo'
9-
version = '2.1.0-SNAPSHOT'
9+
version = '3.0.0-SNAPSHOT'
1010
ext.isReleaseVersion = !version.endsWith("SNAPSHOT")
1111

1212
dependencies {
1313
implementation 'com.fasterxml.jackson.core:jackson-databind:2.17.2'
14+
implementation 'com.github.zafarkhaja:java-semver:0.10.2'
15+
implementation "com.squareup.okhttp3:okhttp:4.12.0"
16+
1417
// For UFC DTOs
1518
implementation 'commons-codec:commons-codec:1.17.0'
1619
implementation 'org.slf4j:slf4j-api:2.0.13'
@@ -19,10 +22,19 @@ dependencies {
1922
testImplementation 'org.skyscreamer:jsonassert:1.5.3'
2023
testImplementation 'commons-io:commons-io:2.11.0'
2124
testImplementation "com.google.truth:truth:1.4.4"
25+
testImplementation 'org.mockito:mockito-core:4.11.0'
26+
testImplementation 'org.mockito:mockito-inline:4.11.0'
2227
}
2328

2429
test {
2530
useJUnitPlatform()
31+
testLogging {
32+
events "started", "passed", "skipped", "failed"
33+
exceptionFormat "full"
34+
showExceptions true
35+
showCauses true
36+
showStackTraces true
37+
}
2638
}
2739

2840
spotless {
@@ -50,11 +62,17 @@ java {
5062
withSourcesJar()
5163
}
5264

65+
tasks.register('testJar', Jar) {
66+
archiveClassifier.set('tests')
67+
from sourceSets.test.output
68+
}
69+
5370
publishing {
5471
publications {
5572
mavenJava(MavenPublication) {
5673
artifactId = 'sdk-common-jvm'
5774
from components.java
75+
artifact testJar // Include the test-jar in the published artifacts
5876
versionMapping {
5977
usage('java-api') {
6078
fromResolutionOf('runtimeClasspath')
@@ -102,7 +120,7 @@ publishing {
102120

103121
// Custom task to ensure we can conditionally publish either a release or snapshot artifact
104122
// based on a command line switch. See github workflow files for more details on usage.
105-
task checkVersion {
123+
tasks.register('checkVersion') {
106124
doLast {
107125
if (!project.hasProperty('release') && !project.hasProperty('snapshot')) {
108126
throw new GradleException("You must specify either -Prelease or -Psnapshot")
@@ -123,21 +141,25 @@ tasks.named('publish').configure {
123141
}
124142

125143
// Conditionally enable or disable publishing tasks
126-
tasks.withType(PublishToMavenRepository) {
144+
tasks.withType(PublishToMavenRepository).configureEach {
127145
onlyIf {
128146
project.ext.has('shouldPublish') && project.ext.shouldPublish
129147
}
130148
}
131149

132-
signing {
133-
sign publishing.publications.mavenJava
134-
if (System.env['CI']) {
135-
useInMemoryPgpKeys(System.env.GPG_PRIVATE_KEY, System.env.GPG_PASSPHRASE)
150+
if (!project.gradle.startParameter.taskNames.contains('publishToMavenLocal')) {
151+
signing {
152+
sign publishing.publications.mavenJava
153+
if (System.env['CI']) {
154+
useInMemoryPgpKeys(System.env.GPG_PRIVATE_KEY, System.env.GPG_PASSPHRASE)
155+
}
136156
}
137157
}
138158

139-
140159
javadoc {
160+
failOnError = false
161+
options.addStringOption('Xdoclint:none', '-quiet')
162+
options.addBooleanOption('failOnError', false)
141163
if (JavaVersion.current().isJava9Compatible()) {
142164
options.addBooleanOption('html5', true)
143165
}
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
package cloud.eppo;
2+
3+
import cloud.eppo.ufc.dto.DiscriminableAttributes;
4+
5+
public class BanditEvaluationResult {
6+
7+
private final String flagKey;
8+
private final String subjectKey;
9+
private final DiscriminableAttributes subjectAttributes;
10+
private final String actionKey;
11+
private final DiscriminableAttributes actionAttributes;
12+
private final double actionScore;
13+
private final double actionWeight;
14+
private final double gamma;
15+
private final double optimalityGap;
16+
17+
public BanditEvaluationResult(
18+
String flagKey,
19+
String subjectKey,
20+
DiscriminableAttributes subjectAttributes,
21+
String actionKey,
22+
DiscriminableAttributes actionAttributes,
23+
double actionScore,
24+
double actionWeight,
25+
double gamma,
26+
double optimalityGap) {
27+
this.flagKey = flagKey;
28+
this.subjectKey = subjectKey;
29+
this.subjectAttributes = subjectAttributes;
30+
this.actionKey = actionKey;
31+
this.actionAttributes = actionAttributes;
32+
this.actionScore = actionScore;
33+
this.actionWeight = actionWeight;
34+
this.gamma = gamma;
35+
this.optimalityGap = optimalityGap;
36+
}
37+
38+
public String getFlagKey() {
39+
return flagKey;
40+
}
41+
42+
public String getSubjectKey() {
43+
return subjectKey;
44+
}
45+
46+
public DiscriminableAttributes getSubjectAttributes() {
47+
return subjectAttributes;
48+
}
49+
50+
public String getActionKey() {
51+
return actionKey;
52+
}
53+
54+
public DiscriminableAttributes getActionAttributes() {
55+
return actionAttributes;
56+
}
57+
58+
public double getActionScore() {
59+
return actionScore;
60+
}
61+
62+
public double getActionWeight() {
63+
return actionWeight;
64+
}
65+
66+
public double getGamma() {
67+
return gamma;
68+
}
69+
70+
public double getOptimalityGap() {
71+
return optimalityGap;
72+
}
73+
}
Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
package cloud.eppo;
2+
3+
import cloud.eppo.ufc.dto.*;
4+
import java.util.*;
5+
import java.util.stream.Collectors;
6+
7+
public class BanditEvaluator {
8+
9+
private static final int BANDIT_ASSIGNMENT_SHARDS = 10000; // hard-coded for now
10+
11+
public static BanditEvaluationResult evaluateBandit(
12+
String flagKey,
13+
String subjectKey,
14+
DiscriminableAttributes subjectAttributes,
15+
Actions actions,
16+
BanditModelData modelData) {
17+
Map<String, Double> actionScores = scoreActions(subjectAttributes, actions, modelData);
18+
Map<String, Double> actionWeights =
19+
weighActions(actionScores, modelData.getGamma(), modelData.getActionProbabilityFloor());
20+
String selectedActionKey = selectAction(flagKey, subjectKey, actionWeights);
21+
22+
// Compute optimality gap in terms of score
23+
double topScore =
24+
actionScores.values().stream().mapToDouble(Double::doubleValue).max().orElse(0);
25+
double optimalityGap = topScore - actionScores.get(selectedActionKey);
26+
27+
return new BanditEvaluationResult(
28+
flagKey,
29+
subjectKey,
30+
subjectAttributes,
31+
selectedActionKey,
32+
actions.get(selectedActionKey),
33+
actionScores.get(selectedActionKey),
34+
actionWeights.get(selectedActionKey),
35+
modelData.getGamma(),
36+
optimalityGap);
37+
}
38+
39+
private static Map<String, Double> scoreActions(
40+
DiscriminableAttributes subjectAttributes, Actions actions, BanditModelData modelData) {
41+
return actions.entrySet().stream()
42+
.collect(
43+
Collectors.toMap(
44+
Map.Entry::getKey,
45+
e -> {
46+
String actionName = e.getKey();
47+
DiscriminableAttributes actionAttributes = e.getValue();
48+
49+
// get all coefficients known to the model for this action
50+
BanditCoefficients banditCoefficients =
51+
modelData.getCoefficients().get(actionName);
52+
53+
if (banditCoefficients == null) {
54+
// Unknown action; return the default action score
55+
return modelData.getDefaultActionScore();
56+
}
57+
58+
// Score the action using the provided attributes
59+
double actionScore = banditCoefficients.getIntercept();
60+
actionScore +=
61+
scoreContextForCoefficients(
62+
actionAttributes.getNumericAttributes(),
63+
banditCoefficients.getActionNumericCoefficients());
64+
actionScore +=
65+
scoreContextForCoefficients(
66+
actionAttributes.getCategoricalAttributes(),
67+
banditCoefficients.getActionCategoricalCoefficients());
68+
actionScore +=
69+
scoreContextForCoefficients(
70+
subjectAttributes.getNumericAttributes(),
71+
banditCoefficients.getSubjectNumericCoefficients());
72+
actionScore +=
73+
scoreContextForCoefficients(
74+
subjectAttributes.getCategoricalAttributes(),
75+
banditCoefficients.getSubjectCategoricalCoefficients());
76+
77+
return actionScore;
78+
}));
79+
}
80+
81+
private static double scoreContextForCoefficients(
82+
Attributes attributes, Map<String, ? extends BanditAttributeCoefficients> coefficients) {
83+
84+
double totalScore = 0.0;
85+
86+
for (BanditAttributeCoefficients attributeCoefficients : coefficients.values()) {
87+
EppoValue contextValue = attributes.get(attributeCoefficients.getAttributeKey());
88+
// The coefficient implementation knows how to score
89+
double attributeScore = attributeCoefficients.scoreForAttributeValue(contextValue);
90+
totalScore += attributeScore;
91+
}
92+
93+
return totalScore;
94+
}
95+
96+
private static Map<String, Double> weighActions(
97+
Map<String, Double> actionScores, double gamma, double actionProbabilityFloor) {
98+
Double highestScore = null;
99+
String highestScoredAction = null;
100+
for (Map.Entry<String, Double> actionScore : actionScores.entrySet()) {
101+
if (highestScore == null
102+
|| actionScore.getValue() > highestScore
103+
|| actionScore
104+
.getValue()
105+
.equals(highestScore) // note: we break ties for scores by action name
106+
&& actionScore.getKey().compareTo(highestScoredAction) < 0) {
107+
highestScore = actionScore.getValue();
108+
highestScoredAction = actionScore.getKey();
109+
}
110+
}
111+
112+
// Weigh all the actions using their score
113+
Map<String, Double> actionWeights = new HashMap<>();
114+
double totalNonHighestWeight = 0.0;
115+
for (Map.Entry<String, Double> actionScore : actionScores.entrySet()) {
116+
if (actionScore.getKey().equals(highestScoredAction)) {
117+
// The highest scored action is weighed at the end
118+
continue;
119+
}
120+
121+
// Compute weight (probability)
122+
double unboundedProbability =
123+
1 / (actionScores.size() + (gamma * (highestScore - actionScore.getValue())));
124+
double minimumProbability = actionProbabilityFloor / actionScores.size();
125+
double boundedProbability = Math.max(unboundedProbability, minimumProbability);
126+
totalNonHighestWeight += boundedProbability;
127+
128+
actionWeights.put(actionScore.getKey(), boundedProbability);
129+
}
130+
131+
// Weigh the highest scoring action (defensively preventing a negative probability)
132+
double weightForHighestScore = Math.max(1 - totalNonHighestWeight, 0);
133+
actionWeights.put(highestScoredAction, weightForHighestScore);
134+
return actionWeights;
135+
}
136+
137+
private static String selectAction(
138+
String flagKey, String subjectKey, Map<String, Double> actionWeights) {
139+
// Deterministically "shuffle" the actions
140+
// This way as action weights shift, a bunch of users who were on the edge of one action won't
141+
// all be shifted to the same new action at the same time.
142+
List<String> shuffledActionKeys =
143+
actionWeights.keySet().stream()
144+
.sorted(
145+
Comparator.comparingInt(
146+
(String actionKey) ->
147+
ShardUtils.getShard(
148+
flagKey + "-" + subjectKey + "-" + actionKey,
149+
BANDIT_ASSIGNMENT_SHARDS))
150+
.thenComparing(actionKey -> actionKey))
151+
.collect(Collectors.toList());
152+
153+
// Select action from the shuffled actions, based on weight
154+
double assignedShard =
155+
ShardUtils.getShard(flagKey + "-" + subjectKey, BANDIT_ASSIGNMENT_SHARDS);
156+
double assignmentWeightThreshold = assignedShard / (double) BANDIT_ASSIGNMENT_SHARDS;
157+
double cumulativeWeight = 0;
158+
String assignedAction = null;
159+
for (String actionKey : shuffledActionKeys) {
160+
cumulativeWeight += actionWeights.get(actionKey);
161+
if (cumulativeWeight > assignmentWeightThreshold) {
162+
assignedAction = actionKey;
163+
break;
164+
}
165+
}
166+
return assignedAction;
167+
}
168+
}

0 commit comments

Comments
 (0)