diff --git a/.github/workflows/lint-test-sdk.yml b/.github/workflows/lint-test-sdk.yml index 62e5c80..2904fcd 100644 --- a/.github/workflows/lint-test-sdk.yml +++ b/.github/workflows/lint-test-sdk.yml @@ -13,15 +13,15 @@ jobs: java-version: ['8', '11', '17'] # Define the Java versions to test against steps: - uses: actions/checkout@v3 - + - name: Set up JDK ${{ matrix.java-version }} uses: actions/setup-java@v3 with: java-version: ${{ matrix.java-version }} distribution: 'adopt' - + - name: 'Set up GCP SDK' - uses: 'google-github-actions/setup-gcloud@v0' - + uses: 'google-github-actions/setup-gcloud@v2' + - name: Run tests run: make test diff --git a/.github/workflows/publish-sdk.yml b/.github/workflows/publish-sdk.yml index c177040..45396a5 100644 --- a/.github/workflows/publish-sdk.yml +++ b/.github/workflows/publish-sdk.yml @@ -20,7 +20,7 @@ jobs: gpg-passphrase: GPG_PASSPHRASE - name: 'Set up GCP SDK for downloading test data' - uses: 'google-github-actions/setup-gcloud@v0' + uses: 'google-github-actions/setup-gcloud@v2' - name: Download test data run: make test-data @@ -31,7 +31,7 @@ jobs: MAVEN_USERNAME: ${{ secrets.OSSRH_USERNAME }} MAVEN_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} GPG_PASSPHRASE: ${{ secrets.GPG_PASSPHRASE }} - + - name: Publish package run: mvn nexus-staging:release env: diff --git a/.gitignore b/.gitignore index 062bbaa..0d4e62e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,36 @@ +# Shared test files +src/test/resources/assignment-v2 +src/test/resources/rac-experiments-v3.json + +### IntelliJ IDEA ### +*.iml .idea/ -target/ out/ -src/test/resources/assignment-v2 -src/test/resources/rac-experiments-v*.json -.DS_Store +target/ +!**/src/main/**/out/ +!**/src/test/**/out/ + +### Eclipse ### +.apt_generated +.classpath +.factorypath +.project +.settings +.springBeans +.sts4-cache +bin/ +!**/src/main/**/bin/ +!**/src/test/**/bin/ + +### NetBeans ### +/nbproject/private/ +/nbbuild/ +/dist/ +/nbdist/ +/.nb-gradle/ + +### VS Code ### +.vscode/ + +### Mac OS ### +.DS_Store \ No newline at end of file diff --git a/Makefile b/Makefile index 0d5274e..1a067bf 100644 --- a/Makefile +++ b/Makefile @@ -29,18 +29,20 @@ build: test-data mvn --batch-mode --update-snapshots package ## test-data -testDataDir := src/test/resources/ -tempDir := ${testDataDir}temp/ -gitDataDir := ${tempDir}sdk-test-data/ +testDataDir := src/test/resources +banditsDataDir := ${testDataDir}/bandits +tempDir := ${testDataDir}/temp +tempBanditsDir := ${tempDir}/bandits +gitDataDir := ${tempDir}/sdk-test-data branchName := main githubRepoLink := https://github.com/Eppo-exp/sdk-test-data.git .PHONY: test-data -test-data: - rm -rf $(testDataDir) - mkdir -p $(tempDir) +test-data: + find ${testDataDir} -mindepth 1 ! -regex '^${banditsDataDir}.*' -delete + mkdir -p ${tempDir} git clone -b ${branchName} --depth 1 --single-branch ${githubRepoLink} ${gitDataDir} - cp ${gitDataDir}rac-experiments-v3.json ${testDataDir} - cp -r ${gitDataDir}assignment-v2 ${testDataDir} + cp ${gitDataDir}/rac-experiments-v3.json ${testDataDir} + cp -r ${gitDataDir}/assignment-v2 ${testDataDir} rm -rf ${tempDir} .PHONY: test diff --git a/pom.xml b/pom.xml index 22fe7cb..bfa60e2 100644 --- a/pom.xml +++ b/pom.xml @@ -6,7 +6,7 @@ cloud.eppo eppo-server-sdk - 2.3.0 + 2.4.0 ${project.groupId}:${project.artifactId} Eppo Server-Side SDK for Java @@ -107,6 +107,11 @@ httpclient 4.5.13 + + ch.qos.logback + logback-classic + 1.4.12 + com.github.tomakehurst wiremock-jre8 @@ -125,6 +130,18 @@ 1.9.5 test + + org.mockito + mockito-core + 5.10.0 + test + + + org.skyscreamer + jsonassert + 1.5.1 + test + @@ -206,4 +223,4 @@ - \ No newline at end of file + diff --git a/src/main/java/com/eppo/sdk/EppoClient.java b/src/main/java/com/eppo/sdk/EppoClient.java index 85e3300..f603f4f 100644 --- a/src/main/java/com/eppo/sdk/EppoClient.java +++ b/src/main/java/com/eppo/sdk/EppoClient.java @@ -1,36 +1,19 @@ package com.eppo.sdk; import com.eppo.sdk.constants.Constants; -import com.eppo.sdk.dto.Allocation; -import com.eppo.sdk.dto.AssignmentLogData; -import com.eppo.sdk.dto.EppoClientConfig; -import com.eppo.sdk.dto.EppoValue; -import com.eppo.sdk.dto.EppoValueType; -import com.eppo.sdk.dto.ExperimentConfiguration; -import com.eppo.sdk.dto.Rule; -import com.eppo.sdk.dto.SubjectAttributes; -import com.eppo.sdk.dto.Variation; +import com.eppo.sdk.dto.*; import com.eppo.sdk.exception.EppoClientIsNotInitializedException; import com.eppo.sdk.exception.InvalidInputException; -import com.eppo.sdk.helpers.AppDetails; -import com.eppo.sdk.helpers.CacheHelper; -import com.eppo.sdk.helpers.ConfigurationStore; -import com.eppo.sdk.helpers.EppoHttpClient; -import com.eppo.sdk.helpers.ExperimentConfigurationRequestor; -import com.eppo.sdk.helpers.ExperimentHelper; -import com.eppo.sdk.helpers.FetchConfigurationsTask; -import com.eppo.sdk.helpers.InputValidator; -import com.eppo.sdk.helpers.RuleValidator; -import com.eppo.sdk.helpers.Shard; +import com.eppo.sdk.helpers.*; +import com.eppo.sdk.helpers.bandit.BanditEvaluator; import com.fasterxml.jackson.databind.JsonNode; import lombok.extern.slf4j.Slf4j; import org.ehcache.Cache; -import java.util.List; -import java.util.Optional; -import java.util.Timer; +import java.util.*; +import java.util.stream.Collectors; @Slf4j public class EppoClient { @@ -61,64 +44,170 @@ private EppoClient(ConfigurationStore configurationStore, Timer poller, EppoClie protected Optional getAssignmentValue( String subjectKey, String flagKey, - SubjectAttributes subjectAttributes) { + EppoAttributes subjectAttributes, + Map actionsWithAttributes + ) { // Validate Input Values InputValidator.validateNotBlank(subjectKey, "Invalid argument: subjectKey cannot be blank"); InputValidator.validateNotBlank(flagKey, "Invalid argument: flagKey cannot be blank"); + VariationAssignmentResult assignmentResult = this.getAssignedVariation(flagKey, subjectKey, subjectAttributes); + + if (assignmentResult == null) { + return Optional.empty(); + } + + Variation assignedVariation = assignmentResult.getVariation(); + Optional assignmentValue = Optional.of(assignedVariation.getTypedValue()); + + // Below is used for logging + String experimentKey = assignmentResult.getExperimentKey(); + String allocationKey = assignmentResult.getAllocationKey(); + String assignedVariationString = assignedVariation.getTypedValue().stringValue(); + AlgorithmType algorithmType = assignedVariation.getAlgorithmType(); + + if (algorithmType == AlgorithmType.OVERRIDE) { + // Assigned variation was from an override; return its value without logging + return assignmentValue; + } else if (algorithmType == AlgorithmType.CONTEXTUAL_BANDIT) { + // Assigned variation is a bandit; need to use the bandit to determine its value + assignmentValue = this.determineAndLogBanditAction(assignmentResult, actionsWithAttributes); + } + + // Log the assignment + try { + this.eppoClientConfig.getAssignmentLogger() + .logAssignment(new AssignmentLogData( + experimentKey, + flagKey, + allocationKey, + assignedVariationString, + subjectKey, + subjectAttributes)); + } catch (Exception e) { + log.warn("Error logging assignment", e); + } + + return assignmentValue; + } + + private VariationAssignmentResult getAssignedVariation(String flagKey, String subjectKey, EppoAttributes subjectAttributes) { + // Fetch Experiment Configuration ExperimentConfiguration configuration = this.configurationStore.getExperimentConfiguration(flagKey); if (configuration == null) { log.warn("[Eppo SDK] No configuration found for key: " + flagKey); - return Optional.empty(); + return null; } // Check if subject has override variations EppoValue subjectVariationOverride = this.getSubjectVariationOverride(subjectKey, configuration); if (!subjectVariationOverride.isNull()) { - return Optional.of(subjectVariationOverride); + // Create placeholder variation for the override + Variation overrideVariation = new Variation(); + overrideVariation.setTypedValue(subjectVariationOverride); + overrideVariation.setAlgorithmType(AlgorithmType.OVERRIDE); + return new VariationAssignmentResult(overrideVariation); } // Check if disabled if (!configuration.isEnabled()) { log.info("[Eppo SDK] No assigned variation because the experiment or feature flag {} is disabled", flagKey); - return Optional.empty(); + return null; } // Find matched rule Optional rule = RuleValidator.findMatchingRule(subjectAttributes, configuration.getRules()); if (!rule.isPresent()) { log.info("[Eppo SDK] No assigned variation. The subject attributes did not match any targeting rules"); - return Optional.empty(); + return null; } // Check if in experiment sample String allocationKey = rule.get().getAllocationKey(); Allocation allocation = configuration.getAllocation(allocationKey); - if (!this.isInExperimentSample(subjectKey, flagKey, configuration.getSubjectShards(), - allocation.getPercentExposure())) { + int subjectShards = configuration.getSubjectShards(); + if (!this.isInExperimentSample(subjectKey, flagKey, subjectShards, + allocation.getPercentExposure())) { log.info("[Eppo SDK] No assigned variation. The subject is not part of the sample population"); - return Optional.empty(); + return null; } + List variations = allocation.getVariations(); + + String experimentKey = ExperimentHelper.generateKey(flagKey, allocationKey); // Used for logging + // Get assigned variation - Variation assignedVariation = this.getAssignedVariation(subjectKey, flagKey, configuration.getSubjectShards(), - allocation.getVariations()); + String assignmentKey = "assignment-" + subjectKey + "-" + flagKey; + Variation assignedVariation = VariationHelper.selectVariation(assignmentKey, subjectShards, variations); + + return new VariationAssignmentResult( + assignedVariation, + subjectKey, + subjectAttributes, + flagKey, + allocationKey, + experimentKey, + subjectShards + ); + } - try { - String experimentKey = ExperimentHelper.generateKey(flagKey, allocationKey); - this.eppoClientConfig.getAssignmentLogger() - .logAssignment(new AssignmentLogData( - experimentKey, - flagKey, - allocationKey, - assignedVariation.getTypedValue().stringValue(), - subjectKey, - subjectAttributes)); - } catch (Exception e) { - // Ignore Exception + private Optional determineAndLogBanditAction(VariationAssignmentResult assignmentResult, Map assignmentOptions) { + String banditName = assignmentResult.getVariation().getTypedValue().stringValue(); + + String banditKey = assignmentResult.getVariation().getTypedValue().stringValue(); + BanditParameters banditParameters = this.configurationStore.getBanditParameters(banditKey); + + List actionVariations = BanditEvaluator.evaluateBanditActions( + assignmentResult.getExperimentKey(), + banditParameters, + assignmentOptions, + assignmentResult.getSubjectKey(), + assignmentResult.getSubjectAttributes(), + assignmentResult.getSubjectShards() + ); + + String actionSelectionKey = "bandit-" + banditName + "-" + assignmentResult.getSubjectKey() + "-" + assignmentResult.getFlagKey(); + Variation selectedAction = VariationHelper.selectVariation(actionSelectionKey, assignmentResult.getSubjectShards(), actionVariations); + + EppoValue actionValue = selectedAction.getTypedValue(); + String actionString = actionValue.stringValue(); + double actionProbability = VariationHelper.variationProbability(selectedAction, assignmentResult.getSubjectShards()); + + if (this.eppoClientConfig.getBanditLogger() != null) { + // Do bandit-specific logging + + String modelVersionToLog = "uninitialized"; // Default model "version" if we have not seen this bandit before or don't have model parameters for it + if (banditParameters != null) { + modelVersionToLog = banditParameters.getModelName() + " " + banditParameters.getModelVersion(); + } + + // Get the action-related attributes + EppoAttributes actionAttributes = new EppoAttributes(); + if (assignmentOptions != null && !assignmentOptions.isEmpty()) { + actionAttributes = assignmentOptions.get(actionString); + } + + Map subjectNumericAttributes = numericAttributes(assignmentResult.getSubjectAttributes()); + Map subjectCategoricalAttributes = categoricalAttributes(assignmentResult.getSubjectAttributes()); + Map actionNumericAttributes = numericAttributes(actionAttributes); + Map actionCategoricalAttributes = categoricalAttributes(actionAttributes); + + this.eppoClientConfig.getBanditLogger().logBanditAction(new BanditLogData( + assignmentResult.getExperimentKey(), + banditName, + assignmentResult.getSubjectKey(), + actionString, + actionProbability, + modelVersionToLog, + subjectNumericAttributes, + subjectCategoricalAttributes, + actionNumericAttributes, + actionCategoricalAttributes + )); } - return Optional.of(assignedVariation.getTypedValue()); + + return Optional.of(actionValue); } /** @@ -130,31 +219,40 @@ protected Optional getAssignmentValue( * @param subjectAttributes * @return */ - private Optional getTypedAssignment(String subjectKey, String experimentKey, EppoValueType type, - SubjectAttributes subjectAttributes) { + private Optional getTypedAssignment( + EppoValueType type, + String subjectKey, + String experimentKey, + EppoAttributes subjectAttributes, + Map actionsWithAttributes + ) { try { - Optional value = this.getAssignmentValue(subjectKey, experimentKey, subjectAttributes); + Optional value = this.getAssignmentValue(subjectKey, experimentKey, subjectAttributes, actionsWithAttributes); if (!value.isPresent()) { return Optional.empty(); } + EppoValue eppoValue = value.get(); + switch (type) { - case BOOLEAN: - return Optional.of(value.get().boolValue()); case NUMBER: - return Optional.of(value.get().doubleValue()); + return Optional.of(eppoValue.doubleValue()); + case BOOLEAN: + return Optional.of(eppoValue.boolValue()); + case ARRAY_OF_STRING: + return Optional.of(eppoValue.arrayValue()); case JSON_NODE: - return Optional.of(value.get().jsonNodeValue()); - default: - return Optional.of(value.get().stringValue()); + return Optional.of(eppoValue.jsonNodeValue()); + default: // strings and null + return Optional.of(eppoValue.stringValue()); } } catch (Exception e) { - // if graceful mode - if (this.eppoClientConfig.isGracefulMode()) { - log.warn("[Eppo SDK] Error getting assignment value: " + e.getMessage()); - return Optional.empty(); - } - throw e; + // if graceful mode + if (this.eppoClientConfig.isGracefulMode()) { + log.warn("[Eppo SDK] Error getting assignment value: " + e.getMessage()); + return Optional.empty(); + } + throw e; } } @@ -167,7 +265,7 @@ private Optional getTypedAssignment(String subjectKey, String experimentKey, * @return */ public Optional getAssignment(String subjectKey, String experimentKey, - SubjectAttributes subjectAttributes) { + EppoAttributes subjectAttributes) { return this.getStringAssignment(subjectKey, experimentKey, subjectAttributes); } @@ -180,33 +278,81 @@ public Optional getAssignment(String subjectKey, String experimentKey, * @return */ public Optional getAssignment(String subjectKey, String experimentKey) { - return this.getStringAssignment(subjectKey, experimentKey, new SubjectAttributes()); + return this.getStringAssignment(subjectKey, experimentKey, new EppoAttributes()); } /** - * This function will return string assignment value - * - * @param subjectKey - * @param experimentKey - * @param subjectAttributes - * @return + * Maps a subject to a variation for a given flag/experiment. + * + * @param subjectKey identifier of the experiment subject, for example a user ID. + * @param flagKey flagKey feature flag, bandit, or experiment identifier + * @return the variation string assigned to the subject, or null if an unrecoverable error was encountered. */ - public Optional getStringAssignment(String subjectKey, String experimentKey, - SubjectAttributes subjectAttributes) { - return (Optional) this.getTypedAssignment(subjectKey, experimentKey, EppoValueType.STRING, - subjectAttributes); + public Optional getStringAssignment(String subjectKey, String flagKey) { + return this.getStringAssignment(subjectKey, flagKey, new EppoAttributes()); } /** - * This function will return string assignment value without passing - * subjectAttributes - * - * @param subjectKey - * @param experimentKey - * @return + * Maps a subject to a variation for a given flag/experiment. + * + * @param subjectKey identifier of the experiment subject, for example a user ID. + * @param flagKey flagKey feature flag, bandit, or experiment identifier + * @param subjectAttributes optional attributes associated with the subject, for example name, email, + * account age, etc. The subject attributes are used for evaluating any targeting + * rules as well as weighting assignment choices for bandits. + * @return the variation string assigned to the subject, or null if an unrecoverable error was encountered. */ - public Optional getStringAssignment(String subjectKey, String experimentKey) { - return this.getStringAssignment(subjectKey, experimentKey, new SubjectAttributes()); + public Optional getStringAssignment(String subjectKey, String flagKey, + EppoAttributes subjectAttributes) { + return this.getStringAssignment(subjectKey, flagKey, subjectAttributes, new HashSet<>()); + } + + /** + * Maps a subject to a variation for a given flag/bandit/experiment. + * + * @param subjectKey identifier of the experiment subject, for example a user ID. + * @param flagKey flagKey feature flag, bandit, or experiment identifier + * @param subjectAttributes optional attributes associated with the subject, for example name, email, + * account age, etc. The subject attributes are used for evaluating any targeting + * rules as well as weighting assignment choices for bandits. + * @param actions used by bandits to know the actions (potential assignments) available. + * @return the variation string assigned to the subject, or null if an unrecoverable error was encountered. + */ + public Optional getStringAssignment( + String subjectKey, + String flagKey, + EppoAttributes subjectAttributes, + Set actions + ) { + Map actionsWithEmptyAttributes = actions.stream() + .collect(Collectors.toMap( + key -> key, + value -> new EppoAttributes() + )); + return this.getStringAssignment(subjectKey, flagKey, subjectAttributes, actionsWithEmptyAttributes); + } + + /** + * Maps a subject to a variation for a given flag/bandit/experiment. + * + * @param subjectKey identifier of the experiment subject, for example a user ID. + * @param flagKey flagKey feature flag, bandit, or experiment identifier + * @param subjectAttributes optional attributes associated with the subject, for example name, email, + * account age, etc. The subject attributes are used for evaluating any targeting + * rules as well as weighting assignment choices for bandits. + * @param actionsWithAttributes used by bandits to know the actions (assignment options) available and any + * attributes associated with that option. + * @return the variation string assigned to the subject, or null if an unrecoverable error was encountered. + */ + public Optional getStringAssignment( + String subjectKey, + String flagKey, + EppoAttributes subjectAttributes, + Map actionsWithAttributes + ) { + @SuppressWarnings("unchecked") + Optional typedAssignment = (Optional) this.getTypedAssignment(EppoValueType.STRING, subjectKey, flagKey, subjectAttributes, actionsWithAttributes); + return typedAssignment; } /** @@ -218,9 +364,8 @@ public Optional getStringAssignment(String subjectKey, String experiment * @return */ public Optional getBooleanAssignment(String subjectKey, String experimentKey, - SubjectAttributes subjectAttributes) { - return (Optional) this.getTypedAssignment(subjectKey, experimentKey, EppoValueType.BOOLEAN, - subjectAttributes); + EppoAttributes subjectAttributes) { + return (Optional) this.getTypedAssignment(EppoValueType.BOOLEAN, subjectKey, experimentKey, subjectAttributes, null); } /** @@ -232,7 +377,7 @@ public Optional getBooleanAssignment(String subjectKey, String experime * @return */ public Optional getBooleanAssignment(String subjectKey, String experimentKey) { - return this.getBooleanAssignment(subjectKey, experimentKey, new SubjectAttributes()); + return this.getBooleanAssignment(subjectKey, experimentKey, new EppoAttributes()); } /** @@ -244,9 +389,8 @@ public Optional getBooleanAssignment(String subjectKey, String experime * @return */ public Optional getDoubleAssignment(String subjectKey, String experimentKey, - SubjectAttributes subjectAttributes) { - return (Optional) this.getTypedAssignment(subjectKey, experimentKey, EppoValueType.NUMBER, - subjectAttributes); + EppoAttributes subjectAttributes) { + return (Optional) this.getTypedAssignment(EppoValueType.NUMBER, subjectKey, experimentKey, subjectAttributes, null); } /** @@ -258,7 +402,7 @@ public Optional getDoubleAssignment(String subjectKey, String experiment * @return */ public Optional getDoubleAssignment(String subjectKey, String experimentKey) { - return this.getDoubleAssignment(subjectKey, experimentKey, new SubjectAttributes()); + return this.getDoubleAssignment(subjectKey, experimentKey, new EppoAttributes()); } /** @@ -270,7 +414,7 @@ public Optional getDoubleAssignment(String subjectKey, String experiment * @return */ public Optional getJSONStringAssignment(String subjectKey, String experimentKey, - SubjectAttributes subjectAttributes) { + EppoAttributes subjectAttributes) { return this.getStringAssignment(subjectKey, experimentKey, subjectAttributes); } @@ -283,7 +427,7 @@ public Optional getJSONStringAssignment(String subjectKey, String experi * @return */ public Optional getJSONStringAssignment(String subjectKey, String experimentKey) { - return this.getJSONStringAssignment(subjectKey, experimentKey, new SubjectAttributes()); + return this.getJSONStringAssignment(subjectKey, experimentKey, new EppoAttributes()); } /** @@ -295,9 +439,9 @@ public Optional getJSONStringAssignment(String subjectKey, String experi * @return */ public Optional getParsedJSONAssignment(String subjectKey, String experimentKey, - SubjectAttributes subjectAttributes) { - return (Optional) this.getTypedAssignment(subjectKey, experimentKey, EppoValueType.JSON_NODE, - subjectAttributes); + EppoAttributes subjectAttributes) { + return (Optional) this.getTypedAssignment(EppoValueType.JSON_NODE, subjectKey, experimentKey, + subjectAttributes, null); } /** @@ -309,7 +453,7 @@ public Optional getParsedJSONAssignment(String subjectKey, String expe * @return */ public Optional getParsedJSONAssignment(String subjectKey, String experimentKey) { - return this.getParsedJSONAssignment(subjectKey, experimentKey, new SubjectAttributes()); + return this.getParsedJSONAssignment(subjectKey, experimentKey, new EppoAttributes()); } /** @@ -325,34 +469,11 @@ private boolean isInExperimentSample( String subjectKey, String experimentKey, int subjectShards, - float percentageExposure) { + double percentageExposure) { int shard = Shard.getShard("exposure-" + subjectKey + "-" + experimentKey, subjectShards); return shard <= percentageExposure * subjectShards; } - /** - * This function is used to get assigned variation - * - * @param subjectKey - * @param experimentKey - * @param subjectShards - * @param subjectShards - * @return - */ - private Variation getAssignedVariation( - String subjectKey, - String experimentKey, - int subjectShards, - List variations) { - int shard = Shard.getShard("assignment-" + subjectKey + "-" + experimentKey, subjectShards); - - Optional variation = variations.stream() - .filter(config -> Shard.isShardInRange(shard, config.getShardRange())) - .findFirst(); - - return variation.get(); - } - /** * This function is used to get override variations. * @@ -364,9 +485,83 @@ private EppoValue getSubjectVariationOverride( String subjectKey, ExperimentConfiguration experimentConfiguration) { String hexedSubjectKey = Shard.getHex(subjectKey); - return experimentConfiguration.getTypedOverrides().getOrDefault(hexedSubjectKey, new EppoValue()); + return experimentConfiguration.getTypedOverrides().getOrDefault(hexedSubjectKey, EppoValue.nullValue()); + } + + /*** + * Logs an action taken that was not selected by the bandit. + * Useful for full transparency on what users experienced. + * @param subjectKey subjectKey identifier of the experiment subject, for example a user ID. + * @param flagKey feature flag, bandit, or experiment identifier + * @param subjectAttributes optional attributes associated with the subject, for example name, email, account age, etc. + * @param actionString name of the action taken for the subject + * @param actionAttributes attributes associated with the given action + * @return null if no exception was encountered by logging; otherwise, the encountered exception + */ + public Exception logNonBanditAction( + String subjectKey, + String flagKey, + EppoAttributes subjectAttributes, + String actionString, + EppoAttributes actionAttributes + ) { + Exception loggingException = null; + try { + VariationAssignmentResult assignmentResult = this.getAssignedVariation(flagKey, subjectKey, subjectAttributes); + + if (assignmentResult == null) { + // No bandit at play + return null; + } + + String variationValue = assignmentResult.getVariation().getTypedValue().toString(); + + Map subjectNumericAttributes = numericAttributes(subjectAttributes); + Map subjectCategoricalAttributes = categoricalAttributes(subjectAttributes); + Map actionNumericAttributes = numericAttributes(actionAttributes); + Map actionCategoricalAttributes = categoricalAttributes(actionAttributes); + + this.eppoClientConfig.getBanditLogger().logBanditAction(new BanditLogData( + assignmentResult.getExperimentKey(), + variationValue, + subjectKey, + actionString, + null, + null, + subjectNumericAttributes, + subjectCategoricalAttributes, + actionNumericAttributes, + actionCategoricalAttributes + )); + } catch (Exception ex) { + loggingException = ex; + } + return loggingException; } + private Map numericAttributes(EppoAttributes eppoAttributes) { + if (eppoAttributes == null) { + return new HashMap<>(); + } + return eppoAttributes.entrySet().stream().filter(e -> e.getValue().isNumeric() + ).collect(Collectors.toMap( + Map.Entry::getKey, + e -> e.getValue().doubleValue()) + ); + } + + private Map categoricalAttributes(EppoAttributes eppoAttributes) { + if (eppoAttributes == null) { + return new HashMap<>(); + } + return eppoAttributes.entrySet().stream().filter(e -> !e.getValue().isNumeric() && !e.getValue().isNull() + ).collect(Collectors.toMap( + Map.Entry::getKey, + e -> e.getValue().toString()) + ); + } + + /*** * This function is used to initialize the Eppo Client * @@ -387,16 +582,24 @@ public static synchronized EppoClient init(EppoClientConfig eppoClientConfig) { eppoClientConfig.getBaseURL(), Constants.REQUEST_TIMEOUT_MILLIS); - // Create wrapper for fetching experiment configuration - ExperimentConfigurationRequestor expConfigRequestor = new ExperimentConfigurationRequestor(eppoHttpClient); - // Create Caching for Experiment Configuration + // Create wrapper for fetching experiment and bandit configuration + ConfigurationRequestor expConfigRequestor = + new ConfigurationRequestor<>(ExperimentConfigurationResponse.class, eppoHttpClient, Constants.RAC_ENDPOINT); + ConfigurationRequestor banditParametersRequestor = + new ConfigurationRequestor<>(BanditParametersResponse.class, eppoHttpClient, Constants.BANDIT_ENDPOINT); + // Create Caching for Experiment Configuration and Bandit Parameters CacheHelper cacheHelper = new CacheHelper(); Cache experimentConfigurationCache = cacheHelper .createExperimentConfigurationCache(Constants.MAX_CACHE_ENTRIES); + Cache banditParametersCache = cacheHelper + .createBanditParameterCache(Constants.MAX_CACHE_ENTRIES); // Create ExperimentConfiguration Store ConfigurationStore configurationStore = ConfigurationStore.init( experimentConfigurationCache, - expConfigRequestor); + expConfigRequestor, + banditParametersCache, + banditParametersRequestor + ); // Stop the polling process of any previously initialized client if (EppoClient.instance != null) { diff --git a/src/main/java/com/eppo/sdk/constants/Constants.java b/src/main/java/com/eppo/sdk/constants/Constants.java index 40a4fc0..08288d3 100644 --- a/src/main/java/com/eppo/sdk/constants/Constants.java +++ b/src/main/java/com/eppo/sdk/constants/Constants.java @@ -32,9 +32,13 @@ public class Constants { */ public static final String RAC_ENDPOINT = "/randomized_assignment/v3/config"; + public static final String BANDIT_ENDPOINT = "/randomized_assignment/v3/bandits"; + /** * Caching Settings */ public static final String EXPERIMENT_CONFIGURATION_CACHE_KEY = "experiment-configuration"; + public static final String BANDIT_PARAMETER_CACHE_KEY = "bandit-parameter"; + } diff --git a/src/main/java/com/eppo/sdk/deserializer/BanditsDeserializer.java b/src/main/java/com/eppo/sdk/deserializer/BanditsDeserializer.java new file mode 100644 index 0000000..5e521e9 --- /dev/null +++ b/src/main/java/com/eppo/sdk/deserializer/BanditsDeserializer.java @@ -0,0 +1,132 @@ +package com.eppo.sdk.deserializer; + +import com.eppo.sdk.dto.*; +import com.fasterxml.jackson.core.JsonParser; +import com.fasterxml.jackson.databind.DeserializationContext; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.deser.std.StdDeserializer; + +import java.io.IOException; +import java.time.Instant; +import java.util.*; + +public class BanditsDeserializer extends StdDeserializer> { + + // Note: public default constructor is required by Jackson + public BanditsDeserializer() { + this(null); + } + + protected BanditsDeserializer(Class vc) { + super(vc); + } + + @Override + public Map deserialize( + JsonParser jsonParser, + DeserializationContext deserializationContext + ) throws IOException { + JsonNode banditsNode = jsonParser.getCodec().readTree(jsonParser); + Map bandits = new HashMap<>(); + banditsNode.iterator().forEachRemaining(banditNode -> { + String banditKey = banditNode.get("banditKey").asText(); + String updatedAtStr = banditNode.get("updatedAt").asText(); + Instant instant = Instant.parse(updatedAtStr); + Date updatedAt = Date.from(instant); + String modelName = banditNode.get("modelName").asText(); + String modelVersion = banditNode.get("modelVersion").asText(); + + BanditParameters parameters = new BanditParameters(); + parameters.setBanditKey(banditKey); + parameters.setUpdatedAt(updatedAt); + parameters.setModelName(modelName); + parameters.setModelVersion(modelVersion); + + BanditModelData modelData = new BanditModelData(); + JsonNode modelDataNode = banditNode.get("modelData"); + double gamma = modelDataNode.get("gamma").asDouble(); + modelData.setGamma(gamma); + double defaultActionScore = modelDataNode.get("defaultActionScore").asDouble(); + modelData.setDefaultActionScore(defaultActionScore); + double actionProbabilityFloor = modelDataNode.get("actionProbabilityFloor").asDouble(); + modelData.setActionProbabilityFloor(actionProbabilityFloor); + JsonNode coefficientsNode = modelDataNode.get("coefficients"); + + Map coefficients = new HashMap<>(); + coefficientsNode.iterator().forEachRemaining(actionCoefficientsNode -> { + BanditCoefficients actionCoefficients = this.parseActionCoefficientsNode(actionCoefficientsNode); + coefficients.put(actionCoefficients.getActionKey(), actionCoefficients); + }); + + modelData.setCoefficients(coefficients); + + parameters.setModelData(modelData); + bandits.put(banditKey, parameters); + }); + return bandits; + } + + private BanditCoefficients parseActionCoefficientsNode(JsonNode actionCoefficientsNode) { + String actionKey = actionCoefficientsNode.get("actionKey").asText(); + Double intercept = actionCoefficientsNode.get("intercept").asDouble(); + + JsonNode subjectNumericAttributeCoefficientsNode = actionCoefficientsNode.get("subjectNumericCoefficients"); + Map subjectNumericAttributeCoefficients = this.parseNumericAttributeCoefficientsArrayNode(subjectNumericAttributeCoefficientsNode); + JsonNode subjectCategoricalAttributeCoefficientsNode = actionCoefficientsNode.get("subjectCategoricalCoefficients"); + Map subjectCategoricalAttributeCoefficients = this.parseCategoricalAttributeCoefficientsArrayNode(subjectCategoricalAttributeCoefficientsNode); + + JsonNode actionNumericAttributeCoefficientsNode = actionCoefficientsNode.get("actionNumericCoefficients"); + Map actionNumericAttributeCoefficients = this.parseNumericAttributeCoefficientsArrayNode(actionNumericAttributeCoefficientsNode); + JsonNode actionCategoricalAttributeCoefficientsNode = actionCoefficientsNode.get("actionCategoricalCoefficients"); + Map actionCategoricalAttributeCoefficients = this.parseCategoricalAttributeCoefficientsArrayNode(actionCategoricalAttributeCoefficientsNode); + + BanditCoefficients coefficients = new BanditCoefficients(); + coefficients.setActionKey(actionKey); + coefficients.setIntercept(intercept); + coefficients.setSubjectNumericCoefficients(subjectNumericAttributeCoefficients); + coefficients.setSubjectCategoricalCoefficients(subjectCategoricalAttributeCoefficients); + coefficients.setActionNumericCoefficients(actionNumericAttributeCoefficients); + coefficients.setActionCategoricalCoefficients(actionCategoricalAttributeCoefficients); + return coefficients; + } + + private Map parseNumericAttributeCoefficientsArrayNode(JsonNode numericAttributeCoefficientsArrayNode) { + Map numericAttributeCoefficients = new HashMap<>(); + numericAttributeCoefficientsArrayNode.iterator().forEachRemaining(numericAttributeCoefficientsNode -> { + String attributeKey = numericAttributeCoefficientsNode.get("attributeKey").asText(); + Double coefficient = numericAttributeCoefficientsNode.get("coefficient").asDouble(); + Double missingValueCoefficient = numericAttributeCoefficientsNode.get("missingValueCoefficient").asDouble(); + + BanditNumericAttributeCoefficients coefficients = new BanditNumericAttributeCoefficients(); + coefficients.setAttributeKey(attributeKey); + coefficients.setCoefficient(coefficient); + coefficients.setMissingValueCoefficient(missingValueCoefficient); + numericAttributeCoefficients.put(attributeKey, coefficients); + }); + + return numericAttributeCoefficients; + } + + private Map parseCategoricalAttributeCoefficientsArrayNode(JsonNode categoricalAttributeCoefficientsArrayNode) { + Map categoricalAttributeCoefficients = new HashMap<>(); + categoricalAttributeCoefficientsArrayNode.iterator().forEachRemaining(categoricalAttributeCoefficientsNode -> { + String attributeKey = categoricalAttributeCoefficientsNode.get("attributeKey").asText(); + Double missingValueCoefficient = categoricalAttributeCoefficientsNode.get("missingValueCoefficient").asDouble(); + Map valueCoefficients = new HashMap<>(); + JsonNode valuesNode = categoricalAttributeCoefficientsNode.get("values"); + valuesNode.iterator().forEachRemaining(valueNode -> { + String value = valueNode.get("value").asText(); + Double coefficient = valueNode.get("coefficient").asDouble(); + valueCoefficients.put(value, coefficient); + }); + + BanditCategoricalAttributeCoefficients coefficients = new BanditCategoricalAttributeCoefficients(); + coefficients.setAttributeKey(attributeKey); + coefficients.setValueCoefficients(valueCoefficients); + coefficients.setMissingValueCoefficient(missingValueCoefficient); + categoricalAttributeCoefficients.put(attributeKey, coefficients); + }); + + return categoricalAttributeCoefficients; + } +} diff --git a/src/main/java/com/eppo/sdk/deserializer/EppoValueDeserializer.java b/src/main/java/com/eppo/sdk/deserializer/EppoValueDeserializer.java index 812f241..58bdc88 100644 --- a/src/main/java/com/eppo/sdk/deserializer/EppoValueDeserializer.java +++ b/src/main/java/com/eppo/sdk/deserializer/EppoValueDeserializer.java @@ -81,7 +81,7 @@ private EppoValue parseEppoValue(JsonNode node) { case POJO: return EppoValue.valueOf(node); default: - return EppoValue.valueOf(); + return EppoValue.nullValue(); } } } diff --git a/src/main/java/com/eppo/sdk/dto/AlgorithmType.java b/src/main/java/com/eppo/sdk/dto/AlgorithmType.java new file mode 100644 index 0000000..c234409 --- /dev/null +++ b/src/main/java/com/eppo/sdk/dto/AlgorithmType.java @@ -0,0 +1,19 @@ +package com.eppo.sdk.dto; + +import com.fasterxml.jackson.annotation.JsonCreator; + +import java.util.Arrays; + +public enum AlgorithmType { + CONSTANT, + CONTEXTUAL_BANDIT, + OVERRIDE; + + @JsonCreator + public static AlgorithmType forValues(String value) { + return Arrays.stream(AlgorithmType.values()) + .filter(a -> a.name().equalsIgnoreCase(value)) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/com/eppo/sdk/dto/Allocation.java b/src/main/java/com/eppo/sdk/dto/Allocation.java index 7d76899..b556efd 100644 --- a/src/main/java/com/eppo/sdk/dto/Allocation.java +++ b/src/main/java/com/eppo/sdk/dto/Allocation.java @@ -6,6 +6,6 @@ @Data public class Allocation { - private float percentExposure; + private double percentExposure; private List variations; } diff --git a/src/main/java/com/eppo/sdk/dto/AssignmentLogData.java b/src/main/java/com/eppo/sdk/dto/AssignmentLogData.java index bd586f2..e06c40f 100644 --- a/src/main/java/com/eppo/sdk/dto/AssignmentLogData.java +++ b/src/main/java/com/eppo/sdk/dto/AssignmentLogData.java @@ -12,7 +12,7 @@ public class AssignmentLogData { public String variation; public Date timestamp; public String subject; - public SubjectAttributes subjectAttributes; + public EppoAttributes subjectAttributes; public AssignmentLogData( String experiment, @@ -20,7 +20,7 @@ public AssignmentLogData( String allocation, String variation, String subject, - SubjectAttributes subjectAttributes + EppoAttributes subjectAttributes ) { this.experiment = experiment; this.featureFlag = featureFlag; diff --git a/src/main/java/com/eppo/sdk/dto/AttributeCoefficients.java b/src/main/java/com/eppo/sdk/dto/AttributeCoefficients.java new file mode 100644 index 0000000..90b4b62 --- /dev/null +++ b/src/main/java/com/eppo/sdk/dto/AttributeCoefficients.java @@ -0,0 +1,7 @@ +package com.eppo.sdk.dto; + +public interface AttributeCoefficients { + + String getAttributeKey(); + double scoreForAttributeValue(EppoValue attributeValue); +} diff --git a/src/main/java/com/eppo/sdk/dto/BanditCategoricalAttributeCoefficients.java b/src/main/java/com/eppo/sdk/dto/BanditCategoricalAttributeCoefficients.java new file mode 100644 index 0000000..c8dcf03 --- /dev/null +++ b/src/main/java/com/eppo/sdk/dto/BanditCategoricalAttributeCoefficients.java @@ -0,0 +1,30 @@ +package com.eppo.sdk.dto; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +import java.util.Map; + +@Slf4j +@Data +public class BanditCategoricalAttributeCoefficients implements AttributeCoefficients { + private String attributeKey; + private Double missingValueCoefficient; + private Map valueCoefficients; + + public double scoreForAttributeValue(EppoValue attributeValue) { + if (attributeValue == null || attributeValue.isNull()) { + return missingValueCoefficient; + } + if (attributeValue.isNumeric()) { + log.warn("Unexpected numeric attribute value for attribute "+attributeKey); + return missingValueCoefficient; + } + + String valueKey = attributeValue.toString(); + Double coefficient = valueCoefficients.get(valueKey); + + // Categorical attributes are treated as one-hot booleans, so it's just the coefficient * 1 when present + return coefficient != null ? coefficient : missingValueCoefficient; + } +} diff --git a/src/main/java/com/eppo/sdk/dto/BanditCoefficients.java b/src/main/java/com/eppo/sdk/dto/BanditCoefficients.java new file mode 100644 index 0000000..89a3fb2 --- /dev/null +++ b/src/main/java/com/eppo/sdk/dto/BanditCoefficients.java @@ -0,0 +1,15 @@ +package com.eppo.sdk.dto; + +import lombok.Data; + +import java.util.Map; + +@Data +public class BanditCoefficients { + private String actionKey; + private Double intercept; + private Map subjectNumericCoefficients; + private Map subjectCategoricalCoefficients; + private Map actionNumericCoefficients; + private Map actionCategoricalCoefficients; +} diff --git a/src/main/java/com/eppo/sdk/dto/BanditLogData.java b/src/main/java/com/eppo/sdk/dto/BanditLogData.java new file mode 100644 index 0000000..51cb37a --- /dev/null +++ b/src/main/java/com/eppo/sdk/dto/BanditLogData.java @@ -0,0 +1,47 @@ +package com.eppo.sdk.dto; + +import java.util.Date; +import java.util.Map; + +/** + * Assignment Log Data Class + */ +public class BanditLogData { + public Date timestamp; + + public String experiment; + public String banditKey; + public String subject; + public String action; + public Double actionProbability; + public String modelVersion; + public Map subjectNumericAttributes; + public Map subjectCategoricalAttributes; + public Map actionNumericAttributes; + public Map actionCategoricalAttributes; + + public BanditLogData( + String experiment, + String banditKey, + String subject, + String action, + Double actionProbability, + String modelVersion, + Map subjectNumericAttributes, + Map subjectCategoricalAttributes, + Map actionNumericAttributes, + Map actionCategoricalAttributes + ) { + this.timestamp = new Date(); + this.experiment = experiment; + this.banditKey = banditKey; + this.subject = subject; + this.action = action; + this.actionProbability = actionProbability; + this.modelVersion = modelVersion; + this.subjectNumericAttributes = subjectNumericAttributes; + this.subjectCategoricalAttributes = subjectCategoricalAttributes; + this.actionNumericAttributes = actionNumericAttributes; + this.actionCategoricalAttributes = actionCategoricalAttributes; + } +} diff --git a/src/main/java/com/eppo/sdk/dto/BanditModelData.java b/src/main/java/com/eppo/sdk/dto/BanditModelData.java new file mode 100644 index 0000000..ee7949c --- /dev/null +++ b/src/main/java/com/eppo/sdk/dto/BanditModelData.java @@ -0,0 +1,13 @@ +package com.eppo.sdk.dto; + +import lombok.Data; + +import java.util.Map; + +@Data +public class BanditModelData { + private Double gamma; + private Double defaultActionScore; + private Double actionProbabilityFloor; + private Map coefficients; +} diff --git a/src/main/java/com/eppo/sdk/dto/BanditNumericAttributeCoefficients.java b/src/main/java/com/eppo/sdk/dto/BanditNumericAttributeCoefficients.java new file mode 100644 index 0000000..d4ae452 --- /dev/null +++ b/src/main/java/com/eppo/sdk/dto/BanditNumericAttributeCoefficients.java @@ -0,0 +1,22 @@ +package com.eppo.sdk.dto; + +import lombok.Data; +import lombok.extern.slf4j.Slf4j; + +@Slf4j +@Data +public class BanditNumericAttributeCoefficients implements AttributeCoefficients { + private String attributeKey; + private Double coefficient; + private Double missingValueCoefficient; + + public double scoreForAttributeValue(EppoValue attributeValue) { + if (attributeValue == null || attributeValue.isNull()) { + return missingValueCoefficient; + } + if (!attributeValue.isNumeric()) { + log.warn("Unexpected categorical attribute value for attribute "+attributeKey); + } + return coefficient * attributeValue.doubleValue(); + } +} diff --git a/src/main/java/com/eppo/sdk/dto/BanditParameters.java b/src/main/java/com/eppo/sdk/dto/BanditParameters.java new file mode 100644 index 0000000..19e7a58 --- /dev/null +++ b/src/main/java/com/eppo/sdk/dto/BanditParameters.java @@ -0,0 +1,13 @@ +package com.eppo.sdk.dto; + +import java.util.Date; +import lombok.Data; + +@Data +public class BanditParameters { + private String banditKey; + private Date updatedAt; + private String modelName; + private String modelVersion; + private BanditModelData modelData; +} diff --git a/src/main/java/com/eppo/sdk/dto/BanditParametersResponse.java b/src/main/java/com/eppo/sdk/dto/BanditParametersResponse.java new file mode 100644 index 0000000..04ce842 --- /dev/null +++ b/src/main/java/com/eppo/sdk/dto/BanditParametersResponse.java @@ -0,0 +1,15 @@ +package com.eppo.sdk.dto; + +import com.eppo.sdk.deserializer.BanditsDeserializer; +import com.fasterxml.jackson.databind.annotation.JsonDeserialize; +import lombok.Data; + +import java.util.Date; +import java.util.Map; + +@Data +public class BanditParametersResponse { + private Date updatedAt; + @JsonDeserialize(using = BanditsDeserializer.class) + private Map bandits; +} diff --git a/src/main/java/com/eppo/sdk/dto/EppoAttributes.java b/src/main/java/com/eppo/sdk/dto/EppoAttributes.java new file mode 100644 index 0000000..f71b78d --- /dev/null +++ b/src/main/java/com/eppo/sdk/dto/EppoAttributes.java @@ -0,0 +1,80 @@ +package com.eppo.sdk.dto; + +import java.util.HashMap; +import java.util.Map; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.node.ObjectNode; + +/** + * Subject Attributes Class + */ +public class EppoAttributes extends HashMap { + + public EppoAttributes() { + super(); + } + + public EppoAttributes(Map initialValues) { + super(initialValues); + } + + public String serializeToJSONString() { + return EppoAttributes.serializeAttributesToJSONString(this); + } + + public static String serializeAttributesToJSONString(Map attributes) { + return EppoAttributes.serializeAttributesToJSONString(attributes, false); + } + + public static String serializeNonNullAttributesToJSONString(Map attributes) { + return EppoAttributes.serializeAttributesToJSONString(attributes, true); + } + + private static String serializeAttributesToJSONString(Map attributes, boolean omitNulls) { + ObjectMapper mapper = new ObjectMapper(); + ObjectNode result = mapper.createObjectNode(); + + for (Map.Entry entry : attributes.entrySet()) { + String attributeName = entry.getKey(); + Object attributeValue = entry.getValue(); + + if (attributeValue instanceof EppoValue) { + EppoValue eppoValue = (EppoValue)attributeValue; + if (eppoValue.isNull()) { + if (!omitNulls) { + result.putNull(attributeName); + } + continue; + } + if (eppoValue.isNumeric()) { + result.put(attributeName, eppoValue.doubleValue()); + continue; + } + if (eppoValue.isBoolean()) { + result.put(attributeName, eppoValue.boolValue()); + continue; + } + // fall back put treating any other eppo values as a string + result.put(attributeName, eppoValue.toString()); + } else if (attributeValue instanceof Double) { + Double doubleValue = (Double)attributeValue; + result.put(attributeName, doubleValue); + } else if (attributeValue == null) { + if (!omitNulls) { + result.putNull(attributeName); + } + } else { + // treat everything else as a string + result.put(attributeName, attributeValue.toString()); + } + } + + try { + return mapper.writeValueAsString(result); + } catch (JsonProcessingException e) { + throw new RuntimeException(e); + } + } +} diff --git a/src/main/java/com/eppo/sdk/dto/EppoClientConfig.java b/src/main/java/com/eppo/sdk/dto/EppoClientConfig.java index 1630372..a04e47b 100644 --- a/src/main/java/com/eppo/sdk/dto/EppoClientConfig.java +++ b/src/main/java/com/eppo/sdk/dto/EppoClientConfig.java @@ -15,6 +15,7 @@ public class EppoClientConfig { @Builder.Default private String baseURL = Constants.DEFAULT_BASE_URL; private IAssignmentLogger assignmentLogger; + private IBanditLogger banditLogger; /** * When set to true, the client will not throw an exception when it encounters diff --git a/src/main/java/com/eppo/sdk/dto/EppoValue.java b/src/main/java/com/eppo/sdk/dto/EppoValue.java index cd74470..7d798b5 100644 --- a/src/main/java/com/eppo/sdk/dto/EppoValue.java +++ b/src/main/java/com/eppo/sdk/dto/EppoValue.java @@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.annotation.JsonDeserialize; import java.util.Arrays; +import java.util.Collections; import java.util.List; /** @@ -12,93 +13,99 @@ */ @JsonDeserialize(using = EppoValueDeserializer.class) public class EppoValue { - private String value; - private JsonNode node; - private EppoValueType type = EppoValueType.NULL; - private List array; + private final EppoValueType type; + private String stringValue; + private Double doubleValue; + private Boolean boolValue; + private JsonNode jsonValue; + private List stringArrayValue; - public EppoValue() { + private EppoValue(String stringValue) { + this.stringValue = stringValue; + this.type = stringValue != null ? EppoValueType.STRING : EppoValueType.NULL; } - public EppoValue(String value, EppoValueType type) { - this.value = value; - this.type = type; + private EppoValue(Double doubleValue) { + this.doubleValue = doubleValue; + this.type = doubleValue != null ? EppoValueType.NUMBER : EppoValueType.NULL; } - - public EppoValue(List array) { - this.array = array; - this.type = EppoValueType.ARRAY_OF_STRING; + private EppoValue(Boolean boolValue) { + this.boolValue = boolValue; + this.type = boolValue != null ? EppoValueType.BOOLEAN : EppoValueType.NULL; } - public EppoValue(JsonNode node) { - this.node = node; - this.value = node.toString(); - this.type = EppoValueType.JSON_NODE; + private EppoValue(List stringArrayValue) { + this.stringArrayValue = stringArrayValue; + this.type = stringArrayValue != null ? EppoValueType.ARRAY_OF_STRING : EppoValueType.NULL; } - public EppoValue(EppoValueType type) { - this.type = type; + private EppoValue(JsonNode jsonValue) { + this.jsonValue = jsonValue; + this.type = EppoValueType.JSON_NODE; } - public static EppoValue valueOf(String value) { - return new EppoValue(value, EppoValueType.STRING); + public static EppoValue valueOf(String stringValue) { + return new EppoValue(stringValue); } - public static EppoValue valueOf(double value) { - return new EppoValue(Double.toString(value), EppoValueType.NUMBER); + public static EppoValue valueOf(double doubleValue) { + return new EppoValue(doubleValue); } - public static EppoValue valueOf(boolean value) { - return new EppoValue(Boolean.toString(value), EppoValueType.BOOLEAN); + public static EppoValue valueOf(boolean boolValue) { + return new EppoValue(boolValue); } - public static EppoValue valueOf(JsonNode node) { - return new EppoValue(node); + public static EppoValue valueOf(JsonNode jsonValue) { + return new EppoValue(jsonValue); } public static EppoValue valueOf(List value) { return new EppoValue(value); } - public static EppoValue valueOf() { - return new EppoValue(EppoValueType.NULL); + public static EppoValue nullValue() { + return new EppoValue((String)null); } public double doubleValue() { - return Double.parseDouble(value); + return this.doubleValue; } public String stringValue() { - return value; + return this.stringValue; } public boolean boolValue() { - return Boolean.valueOf(value); + return this.boolValue; } public JsonNode jsonNodeValue() { - return this.node; + return this.jsonValue; } public List arrayValue() { - return array; + return this.stringArrayValue; + } + + public boolean isString() { + return this.type == EppoValueType.STRING; } public boolean isNumeric() { - try { - Double.parseDouble(value); - return true; - } catch (Exception e) { - return false; - } + return this.type == EppoValueType.NUMBER; + } + + public boolean isBoolean() { + return this.type == EppoValueType.BOOLEAN; } public boolean isArray() { return type == EppoValueType.ARRAY_OF_STRING; } - public boolean isBool() { - return type == EppoValueType.BOOLEAN; + public boolean isJson() { + return type == EppoValueType.JSON_NODE; } public boolean isNull() { @@ -107,12 +114,19 @@ public boolean isNull() { @Override public String toString() { - if (this.isArray()) { - // Assuming this.array is an array, use Arrays.asList() for compatibility with Java 8 - return Arrays.asList(this.array).toString(); - } else if (this.type == EppoValueType.JSON_NODE) { - return this.node.toString(); + switch(this.type) { + case STRING: + return this.stringValue; + case NUMBER: + return this.doubleValue.toString(); + case BOOLEAN: + return this.boolValue.toString(); + case ARRAY_OF_STRING: + return Collections.singletonList(this.stringArrayValue).toString(); + case JSON_NODE: + return this.jsonValue.toString(); + default: // NULL + return ""; } - return this.value; } } diff --git a/src/main/java/com/eppo/sdk/dto/IBanditLogger.java b/src/main/java/com/eppo/sdk/dto/IBanditLogger.java new file mode 100644 index 0000000..3d503ee --- /dev/null +++ b/src/main/java/com/eppo/sdk/dto/IBanditLogger.java @@ -0,0 +1,8 @@ +package com.eppo.sdk.dto; + +/** + * Assignment Logger Interface + */ +public interface IBanditLogger { + void logBanditAction(BanditLogData logData); +} diff --git a/src/main/java/com/eppo/sdk/dto/SubjectAttributes.java b/src/main/java/com/eppo/sdk/dto/SubjectAttributes.java deleted file mode 100644 index 7a5f22a..0000000 --- a/src/main/java/com/eppo/sdk/dto/SubjectAttributes.java +++ /dev/null @@ -1,8 +0,0 @@ -package com.eppo.sdk.dto; - -import java.util.HashMap; - -/** - * Subject Attributes Class - */ -public class SubjectAttributes extends HashMap {} diff --git a/src/main/java/com/eppo/sdk/dto/Variation.java b/src/main/java/com/eppo/sdk/dto/Variation.java index d9785a3..a4900ba 100644 --- a/src/main/java/com/eppo/sdk/dto/Variation.java +++ b/src/main/java/com/eppo/sdk/dto/Variation.java @@ -7,6 +7,8 @@ */ @Data public class Variation { + private String name; private EppoValue typedValue; private ShardRange shardRange; + private AlgorithmType algorithmType; } diff --git a/src/main/java/com/eppo/sdk/helpers/AppDetails.java b/src/main/java/com/eppo/sdk/helpers/AppDetails.java index a7e5a1a..5bee270 100644 --- a/src/main/java/com/eppo/sdk/helpers/AppDetails.java +++ b/src/main/java/com/eppo/sdk/helpers/AppDetails.java @@ -1,9 +1,12 @@ package com.eppo.sdk.helpers; +import lombok.extern.slf4j.Slf4j; + import java.io.IOException; import java.io.InputStream; import java.util.Properties; +@Slf4j public class AppDetails { static AppDetails instance; @@ -12,18 +15,18 @@ public class AppDetails { public static AppDetails getInstance () { if (AppDetails.instance == null){ - try { - AppDetails.instance = new AppDetails(); - } - catch (Exception e) { - throw new RuntimeException("Unable to read properties file!"); - } + AppDetails.instance = new AppDetails(); } return AppDetails.instance; } - public AppDetails() throws IOException { - Properties prop = readPropertiesFile("app.properties"); + public AppDetails() { + Properties prop = new Properties(); + try { + prop = readPropertiesFile("app.properties"); + } catch (Exception ex) { + log.warn("Unable to read properties file", ex); + } this.version = prop.getProperty("app.version", "1.0.0"); this.name = prop.getProperty("app.name", "java-server-sdk"); } @@ -39,9 +42,8 @@ public String getName() { public static Properties readPropertiesFile(String fileName) throws IOException { ClassLoader loader = Thread.currentThread().getContextClassLoader(); Properties props = new Properties(); - try(InputStream resourceStream = loader.getResourceAsStream(fileName)) { - props.load(resourceStream); - } + InputStream resourceStream = loader.getResourceAsStream(fileName); + props.load(resourceStream); return props; } diff --git a/src/main/java/com/eppo/sdk/helpers/CacheHelper.java b/src/main/java/com/eppo/sdk/helpers/CacheHelper.java index ed438d8..c4fac75 100644 --- a/src/main/java/com/eppo/sdk/helpers/CacheHelper.java +++ b/src/main/java/com/eppo/sdk/helpers/CacheHelper.java @@ -1,6 +1,7 @@ package com.eppo.sdk.helpers; import com.eppo.sdk.constants.Constants; +import com.eppo.sdk.dto.BanditParameters; import com.eppo.sdk.dto.ExperimentConfiguration; import org.ehcache.Cache; import org.ehcache.CacheManager; @@ -35,4 +36,15 @@ public Cache createExperimentConfigurationCache ) ); } + + public Cache createBanditParameterCache(int maxEntries) { + return this.cacheManager.createCache( + Constants.BANDIT_PARAMETER_CACHE_KEY, + CacheConfigurationBuilder + .newCacheConfigurationBuilder( + String.class, BanditParameters.class, + ResourcePoolsBuilder.heap(maxEntries) + ) + ); + } } diff --git a/src/main/java/com/eppo/sdk/helpers/ExperimentConfigurationRequestor.java b/src/main/java/com/eppo/sdk/helpers/ConfigurationRequestor.java similarity index 54% rename from src/main/java/com/eppo/sdk/helpers/ExperimentConfigurationRequestor.java rename to src/main/java/com/eppo/sdk/helpers/ConfigurationRequestor.java index e12344a..1d51043 100644 --- a/src/main/java/com/eppo/sdk/helpers/ExperimentConfigurationRequestor.java +++ b/src/main/java/com/eppo/sdk/helpers/ConfigurationRequestor.java @@ -5,39 +5,32 @@ import lombok.extern.slf4j.Slf4j; -import com.eppo.sdk.constants.Constants; -import com.eppo.sdk.dto.ExperimentConfigurationResponse; import com.eppo.sdk.exception.InvalidApiKeyException; import org.apache.http.HttpResponse; import org.apache.http.util.EntityUtils; import java.util.Optional; -/** - * Experiment Configuration Requestor Class - */ @Slf4j -public class ExperimentConfigurationRequestor { - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); - private EppoHttpClient eppoHttpClient; - - public ExperimentConfigurationRequestor(EppoHttpClient eppoHttpClient) { +public class ConfigurationRequestor { + private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper().configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + private final Class responseClass; + private final EppoHttpClient eppoHttpClient; + private final String endpoint; + + public ConfigurationRequestor(Class responseClass, EppoHttpClient eppoHttpClient, String endpoint) { + this.responseClass = responseClass; this.eppoHttpClient = eppoHttpClient; + this.endpoint = endpoint; } - /** - * This function is used to fetch Experiment Configuration - * - * @return - */ - public Optional fetchExperimentConfiguration() { - ExperimentConfigurationResponse config = null; + public Optional fetchConfiguration() { + T config = null; try { - HttpResponse response = this.eppoHttpClient.get(Constants.RAC_ENDPOINT); + HttpResponse response = this.eppoHttpClient.get(this.endpoint); int statusCode = response.getStatusLine().getStatusCode(); if (statusCode == 200) { - config = OBJECT_MAPPER.readValue(EntityUtils.toString(response.getEntity()), ExperimentConfigurationResponse.class); + config = OBJECT_MAPPER.readValue(EntityUtils.toString(response.getEntity()), this.responseClass); } if (statusCode == 401) { // unauthorized - invalid API key throw new InvalidApiKeyException("Unauthorized: invalid Eppo API key."); diff --git a/src/main/java/com/eppo/sdk/helpers/ConfigurationStore.java b/src/main/java/com/eppo/sdk/helpers/ConfigurationStore.java index ed7442a..3c78c5e 100644 --- a/src/main/java/com/eppo/sdk/helpers/ConfigurationStore.java +++ b/src/main/java/com/eppo/sdk/helpers/ConfigurationStore.java @@ -1,10 +1,10 @@ package com.eppo.sdk.helpers; -import com.eppo.sdk.dto.ExperimentConfiguration; -import com.eppo.sdk.dto.ExperimentConfigurationResponse; +import com.eppo.sdk.dto.*; import com.eppo.sdk.exception.ExperimentConfigurationNotFound; import com.eppo.sdk.exception.NetworkException; import com.eppo.sdk.exception.NetworkRequestNotAllowed; +import lombok.extern.slf4j.Slf4j; import org.ehcache.Cache; import java.util.Map; @@ -13,32 +13,39 @@ /** * Configuration Store Class */ +@Slf4j public class ConfigurationStore { Cache experimentConfigurationCache; - ExperimentConfigurationRequestor experimentConfigurationRequestor; + Cache banditParametersCache; + ConfigurationRequestor experimentConfigurationRequestor; + ConfigurationRequestor banditParametersRequestor; static ConfigurationStore instance = null; public ConfigurationStore( Cache experimentConfigurationCache, - ExperimentConfigurationRequestor experimentConfigurationRequestor) { + ConfigurationRequestor experimentConfigurationRequestor, + Cache banditParametersCache, + ConfigurationRequestor banditParametersRequestor + ) { this.experimentConfigurationRequestor = experimentConfigurationRequestor; this.experimentConfigurationCache = experimentConfigurationCache; + this.banditParametersCache = banditParametersCache; + this.banditParametersRequestor = banditParametersRequestor; } - /** - * This function is used to initialize configuration store - * - * @param experimentConfigurationCache - * @param experimentConfigurationRequestor - * @return - */ public final static ConfigurationStore init( Cache experimentConfigurationCache, - ExperimentConfigurationRequestor experimentConfigurationRequestor) { + ConfigurationRequestor experimentConfigurationRequestor, + Cache banditParametersCache, + ConfigurationRequestor banditParametersRequestor + ) { if (ConfigurationStore.instance == null) { ConfigurationStore.instance = new ConfigurationStore( experimentConfigurationCache, - experimentConfigurationRequestor); + experimentConfigurationRequestor, + banditParametersCache, + banditParametersRequestor + ); } instance.experimentConfigurationCache.clear(); return ConfigurationStore.instance; @@ -59,7 +66,7 @@ public final static ConfigurationStore getInstance() { * @param key * @param experimentConfiguration */ - public void setExperimentConfiguration(String key, ExperimentConfiguration experimentConfiguration) { + protected void setExperimentConfiguration(String key, ExperimentConfiguration experimentConfiguration) { this.experimentConfigurationCache.put(key, experimentConfiguration); } @@ -80,6 +87,10 @@ public ExperimentConfiguration getExperimentConfiguration(String key) } + public BanditParameters getBanditParameters(String banditKey) { + return this.banditParametersCache.get(banditKey); + } + /** * This function is used to set experiment configuration int the cache * @@ -88,11 +99,35 @@ public ExperimentConfiguration getExperimentConfiguration(String key) */ public void fetchAndSetExperimentConfiguration() throws NetworkException, NetworkRequestNotAllowed { Optional response = this.experimentConfigurationRequestor - .fetchExperimentConfiguration(); + .fetchConfiguration(); + boolean loadBandits = false; if (response.isPresent()) { for (Map.Entry entry : response.get().getFlags().entrySet()) { - this.setExperimentConfiguration(entry.getKey(), entry.getValue()); + ExperimentConfiguration configuration = entry.getValue(); + this.setExperimentConfiguration(entry.getKey(), configuration); + boolean hasBanditVariation = + configuration + .getAllocations() + .values() + .stream().anyMatch( + a -> a.getVariations().stream().anyMatch(v -> v.getAlgorithmType() == AlgorithmType.CONTEXTUAL_BANDIT) + ); + + if (configuration.isEnabled() && hasBanditVariation) { + loadBandits = true; + } + } + } + + if (loadBandits) { + Optional banditResponse = this.banditParametersRequestor.fetchConfiguration(); + if (!banditResponse.isPresent() || banditResponse.get().getBandits() == null) { + log.warn("Unexpected empty bandit parameter response"); + return; + } + for (Map.Entry entry : banditResponse.get().getBandits().entrySet()) { + this.banditParametersCache.put(entry.getKey(), entry.getValue()); } } } diff --git a/src/main/java/com/eppo/sdk/helpers/FetchConfigurationsTask.java b/src/main/java/com/eppo/sdk/helpers/FetchConfigurationsTask.java index 1c45101..5d4f7d7 100644 --- a/src/main/java/com/eppo/sdk/helpers/FetchConfigurationsTask.java +++ b/src/main/java/com/eppo/sdk/helpers/FetchConfigurationsTask.java @@ -20,6 +20,7 @@ public void run() { // Uncaught runtime exceptions will prevent this task from being rescheduled. // As a result, the SDK will continue functioning using the in-memory cache, but will never attempt // to synchronize with Eppo Cloud again. + // TODO: retry on failed fetches try { configurationStore.fetchAndSetExperimentConfiguration(); } catch (Exception e) { diff --git a/src/main/java/com/eppo/sdk/helpers/RuleValidator.java b/src/main/java/com/eppo/sdk/helpers/RuleValidator.java index 85ccc9f..196af46 100644 --- a/src/main/java/com/eppo/sdk/helpers/RuleValidator.java +++ b/src/main/java/com/eppo/sdk/helpers/RuleValidator.java @@ -1,7 +1,7 @@ package com.eppo.sdk.helpers; import com.eppo.sdk.dto.EppoValue; -import com.eppo.sdk.dto.SubjectAttributes; +import com.eppo.sdk.dto.EppoAttributes; import com.eppo.sdk.exception.InvalidSubjectAttribute; import com.github.zafarkhaja.semver.Version; import com.eppo.sdk.dto.Condition; @@ -63,8 +63,9 @@ public class RuleValidator { * @return */ public static Optional findMatchingRule( - SubjectAttributes subjectAttributes, - List rules) { + EppoAttributes subjectAttributes, + List rules + ) { for (Rule rule : rules) { if (RuleValidator.matchesRule(subjectAttributes, rule)) { return Optional.of(rule); @@ -82,10 +83,10 @@ public static Optional findMatchingRule( * @throws InvalidSubjectAttribute */ private static boolean matchesRule( - SubjectAttributes subjectAttributes, - Rule rule) throws InvalidSubjectAttribute { - List conditionEvaluations = RuleValidator.evaluateRuleConditions(subjectAttributes, - rule.getConditions()); + EppoAttributes subjectAttributes, + Rule rule + ) throws InvalidSubjectAttribute { + List conditionEvaluations = RuleValidator.evaluateRuleConditions(subjectAttributes, rule.getConditions()); return !conditionEvaluations.contains(false); } @@ -98,8 +99,9 @@ private static boolean matchesRule( * @throws InvalidSubjectAttribute */ private static boolean evaluateCondition( - SubjectAttributes subjectAttributes, - Condition condition) throws InvalidSubjectAttribute { + EppoAttributes subjectAttributes, + Condition condition + ) throws InvalidSubjectAttribute { if (subjectAttributes.containsKey(condition.attribute)) { EppoValue value = subjectAttributes.get(condition.attribute); Optional valueSemVer = Version.tryParse(value.stringValue()); @@ -172,8 +174,9 @@ private static boolean evaluateCondition( * @throws InvalidSubjectAttribute */ private static List evaluateRuleConditions( - SubjectAttributes subjectAttributes, - List conditions) throws InvalidSubjectAttribute { + EppoAttributes subjectAttributes, + List conditions + ) throws InvalidSubjectAttribute { return conditions.stream() .map((condition) -> { try { diff --git a/src/main/java/com/eppo/sdk/helpers/Shard.java b/src/main/java/com/eppo/sdk/helpers/Shard.java index 6d133e6..a09a9dc 100644 --- a/src/main/java/com/eppo/sdk/helpers/Shard.java +++ b/src/main/java/com/eppo/sdk/helpers/Shard.java @@ -12,7 +12,7 @@ public class Shard { /** - * This function is used to convert input into md4 hex + * This function is used to convert input into md5 hex * * @param input * @return @@ -42,9 +42,9 @@ public static String getHex(String input) { * @return */ public static int getShard(String input, int maxShardValue) { - String hashText = Shard.getHex(input); + StringBuilder hashText = new StringBuilder(Shard.getHex(input)); while (hashText.length() < 32) { - hashText = "0" + hashText; + hashText.insert(0, "0"); } return (int) (Long.parseLong(hashText.substring(0, 8), 16) % maxShardValue); } diff --git a/src/main/java/com/eppo/sdk/helpers/VariationAssignmentResult.java b/src/main/java/com/eppo/sdk/helpers/VariationAssignmentResult.java new file mode 100644 index 0000000..4e3111f --- /dev/null +++ b/src/main/java/com/eppo/sdk/helpers/VariationAssignmentResult.java @@ -0,0 +1,37 @@ +package com.eppo.sdk.helpers; + +import com.eppo.sdk.dto.EppoAttributes; +import com.eppo.sdk.dto.Variation; +import lombok.Getter; + +@Getter +public class VariationAssignmentResult { + private final Variation variation; + private final String subjectKey; + private final EppoAttributes subjectAttributes; + private final String flagKey; + private final String experimentKey; + private final String allocationKey; + private final Integer subjectShards; + + public VariationAssignmentResult(Variation variation) { + this(variation, null, null, null, null, null, null); + } + public VariationAssignmentResult( + Variation assignedVariation, + String subjectKey, + EppoAttributes subjectAttributes, + String flagKey, + String allocationKey, + String experimentKey, + Integer subjectShards + ) { + this.variation = assignedVariation; + this.subjectKey = subjectKey; + this.subjectAttributes = subjectAttributes; + this.flagKey = flagKey; + this.allocationKey = allocationKey; + this.experimentKey = experimentKey; + this.subjectShards = subjectShards; + } +} diff --git a/src/main/java/com/eppo/sdk/helpers/VariationHelper.java b/src/main/java/com/eppo/sdk/helpers/VariationHelper.java new file mode 100644 index 0000000..6d916d6 --- /dev/null +++ b/src/main/java/com/eppo/sdk/helpers/VariationHelper.java @@ -0,0 +1,28 @@ +package com.eppo.sdk.helpers; + +import com.eppo.sdk.dto.Variation; + +import java.util.List; +import java.util.NoSuchElementException; +import java.util.Optional; + +public class VariationHelper { + + static public Variation selectVariation(String inputKey, int subjectShards, List variations) { + int shard = Shard.getShard(inputKey, subjectShards); + + Optional variation = variations.stream() + .filter(config -> Shard.isShardInRange(shard, config.getShardRange())) + .findFirst(); + + if (!variation.isPresent()) { + throw new NoSuchElementException("Variation shards configured incorrectly for input "+inputKey); + } + + return variation.get(); + } + + static public double variationProbability(Variation variation, int subjectShards) { + return (double)(variation.getShardRange().end - variation.getShardRange().start) / subjectShards; + } +} diff --git a/src/main/java/com/eppo/sdk/helpers/bandit/BanditEvaluator.java b/src/main/java/com/eppo/sdk/helpers/bandit/BanditEvaluator.java new file mode 100644 index 0000000..1bf4bab --- /dev/null +++ b/src/main/java/com/eppo/sdk/helpers/bandit/BanditEvaluator.java @@ -0,0 +1,74 @@ +package com.eppo.sdk.helpers.bandit; + +import com.eppo.sdk.dto.*; +import com.eppo.sdk.helpers.Shard; + +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; + +public class BanditEvaluator { + + public static List evaluateBanditActions( + String experimentKey, + BanditParameters modelParameters, + Map actions, + String subjectKey, + EppoAttributes subjectAttributes, + int subjectShards + ) { + String modelName = modelParameters != null + ? modelParameters.getModelName() + : RandomBanditModel.MODEL_IDENTIFIER; // Default to random model for unknown bandits + + BanditModel model = BanditModelFactory.build(modelName); + Map actionWeights = model.weighActions(modelParameters, actions, subjectAttributes); + List shuffledActions = shuffleActions(actions.keySet(), experimentKey, subjectKey); + return generateVariations(shuffledActions, actionWeights, subjectShards); + } + + private static List shuffleActions(Set actionKeys, String experimentKey, String subjectKey) { + // Shuffle randomly (but deterministically) using a hash, tie-breaking with name + return actionKeys + .stream() + .sorted(Comparator.comparingInt((String actionKey) -> hashToPositiveInt(experimentKey, subjectKey, actionKey)).thenComparing(actionKey -> actionKey)) + .collect(Collectors.toList()); + } + + private static int hashToPositiveInt(String experimentKey, String subjectKey, String actionKey) { + int SHUFFLE_SHARDS = 10000; + return Shard.getShard(experimentKey+"-"+subjectKey+"-"+actionKey, SHUFFLE_SHARDS); + } + + private static List generateVariations(List shuffledActions, Map actionWeights, int subjectShards) { + + final AtomicInteger lastShard = new AtomicInteger(0); + + List variations = shuffledActions.stream().map(actionName -> { + double weight = actionWeights.get(actionName); + int numShards = Double.valueOf(Math.floor(weight * subjectShards)).intValue(); + int shardStart = lastShard.get(); + int shardEnd = shardStart + numShards; + lastShard.set(shardEnd); + + ShardRange shardRange = new ShardRange(); + shardRange.start = shardStart; + shardRange.end = shardEnd; + + Variation variation = new Variation(); + variation.setTypedValue(EppoValue.valueOf(actionName)); + variation.setShardRange(shardRange); + + return variation; + }).collect(Collectors.toList()); + + // Pad last shard if needed due to rounding of weights + Variation lastVariation = variations.get(variations.size() - 1); + lastVariation.getShardRange().end = Math.max(lastVariation.getShardRange().end, subjectShards); + + return variations; + } +} diff --git a/src/main/java/com/eppo/sdk/helpers/bandit/BanditModel.java b/src/main/java/com/eppo/sdk/helpers/bandit/BanditModel.java new file mode 100644 index 0000000..3e4af03 --- /dev/null +++ b/src/main/java/com/eppo/sdk/helpers/bandit/BanditModel.java @@ -0,0 +1,12 @@ +package com.eppo.sdk.helpers.bandit; + +import com.eppo.sdk.dto.BanditParameters; +import com.eppo.sdk.dto.EppoAttributes; + +import java.util.Map; + +public interface BanditModel { + + Map weighActions(BanditParameters parameters, Map actions, EppoAttributes subjectAttributes); + +} diff --git a/src/main/java/com/eppo/sdk/helpers/bandit/BanditModelFactory.java b/src/main/java/com/eppo/sdk/helpers/bandit/BanditModelFactory.java new file mode 100644 index 0000000..a3bf221 --- /dev/null +++ b/src/main/java/com/eppo/sdk/helpers/bandit/BanditModelFactory.java @@ -0,0 +1,15 @@ +package com.eppo.sdk.helpers.bandit; + +public class BanditModelFactory { + + public static BanditModel build(String modelName) { + switch(modelName) { + case RandomBanditModel.MODEL_IDENTIFIER: + return new RandomBanditModel(); + case FalconBanditModel.MODEL_IDENTIFIER: + return new FalconBanditModel(); + default: + throw new IllegalArgumentException("Unknown bandit model " + modelName); + } + } +} diff --git a/src/main/java/com/eppo/sdk/helpers/bandit/FalconBanditModel.java b/src/main/java/com/eppo/sdk/helpers/bandit/FalconBanditModel.java new file mode 100644 index 0000000..1ac850c --- /dev/null +++ b/src/main/java/com/eppo/sdk/helpers/bandit/FalconBanditModel.java @@ -0,0 +1,94 @@ +package com.eppo.sdk.helpers.bandit; + +import com.eppo.sdk.dto.*; + +import java.util.HashMap; +import java.util.Map; +import java.util.stream.Collectors; + +public class FalconBanditModel implements BanditModel { + + public static final String MODEL_IDENTIFIER = "falcon"; + + public Map weighActions(BanditParameters parameters, Map actions, EppoAttributes subjectAttributes) { + + BanditModelData modelData = parameters.getModelData(); + + // For each action we need to compute its score using the model coefficients + Map actionScores = actions.entrySet().stream().collect(Collectors.toMap( + Map.Entry::getKey, e -> { + String actionName = e.getKey(); + EppoAttributes actionAttributes = e.getValue(); + double actionScore = modelData.getDefaultActionScore(); + + // get all coefficients known to the model for this action + BanditCoefficients banditCoefficients = modelData.getCoefficients().get(actionName); + + if (banditCoefficients == null) { + // Unknown action; return default score of 0 + return actionScore; + } + + actionScore += banditCoefficients.getIntercept(); + + actionScore += scoreContextForCoefficients(actionAttributes, banditCoefficients.getActionNumericCoefficients()); + actionScore += scoreContextForCoefficients(actionAttributes, banditCoefficients.getActionCategoricalCoefficients()); + actionScore += scoreContextForCoefficients(subjectAttributes, banditCoefficients.getSubjectNumericCoefficients()); + actionScore += scoreContextForCoefficients(subjectAttributes, banditCoefficients.getSubjectCategoricalCoefficients()); + + return actionScore; + }) + ); + + // Convert scores to weights (probabilities between 0 and 1 that collectively add up to 1.0) + Map actionWeights = computeActionWeights(actionScores, modelData.getGamma(), modelData.getActionProbabilityFloor()); + return actionWeights; + } + + private static double scoreContextForCoefficients(EppoAttributes context, Map coefficients) { + + double totalScore = 0.0; + + for (AttributeCoefficients attributeCoefficients : coefficients.values()) { + EppoValue contextValue = context.get(attributeCoefficients.getAttributeKey()); + double attributeScore = attributeCoefficients.scoreForAttributeValue(contextValue); + totalScore += attributeScore; + } + + return totalScore; + } + + private static Map computeActionWeights(Map actionScores, double gamma, double actionProbabilityFloor) { + Double highestScore = null; + String highestScoredAction = null; + for (Map.Entry actionScore : actionScores.entrySet()) { + if (highestScore == null || actionScore.getValue() > highestScore) { + highestScore = actionScore.getValue(); + highestScoredAction = actionScore.getKey(); + } + } + + // Weigh all the actions using their score + Map actionWeights = new HashMap<>(); + double totalNonHighestWeight = 0.0; + for (Map.Entry actionScore : actionScores.entrySet()) { + if (actionScore.getKey().equals(highestScoredAction)) { + // The highest scored action is weighed at the end + continue; + } + + // Compute weight and round to four decimal places + double unroundedProbability = 1 / (actionScores.size() + (gamma * (highestScore - actionScore.getValue()))); + double boundedProbability = Math.max(unroundedProbability, actionProbabilityFloor); + double roundedProbability = Math.round(boundedProbability * 10000d) / 10000d; + totalNonHighestWeight += roundedProbability; + + actionWeights.put(actionScore.getKey(), boundedProbability); + } + + // Weigh the highest scoring action (defensively preventing a negative probability) + double weightForHighestScore = Math.max(1 - totalNonHighestWeight, 0); + actionWeights.put(highestScoredAction, weightForHighestScore); + return actionWeights; + } +} diff --git a/src/main/java/com/eppo/sdk/helpers/bandit/RandomBanditModel.java b/src/main/java/com/eppo/sdk/helpers/bandit/RandomBanditModel.java new file mode 100644 index 0000000..92504a2 --- /dev/null +++ b/src/main/java/com/eppo/sdk/helpers/bandit/RandomBanditModel.java @@ -0,0 +1,20 @@ +package com.eppo.sdk.helpers.bandit; + +import com.eppo.sdk.dto.BanditParameters; +import com.eppo.sdk.dto.EppoAttributes; + +import java.util.Map; +import java.util.stream.Collectors; + +public class RandomBanditModel implements BanditModel { + + public static final String MODEL_IDENTIFIER = "random"; + + public Map weighActions(BanditParameters parameters, Map actions, EppoAttributes subjectAttributes) { + final double weightPerAction = 1 / (double)actions.size(); + return actions.keySet().stream().collect(Collectors.toMap( + key -> key, + value -> weightPerAction + )); + } +} diff --git a/src/main/resources/logback-test.xml b/src/main/resources/logback-test.xml new file mode 100644 index 0000000..06a761a --- /dev/null +++ b/src/main/resources/logback-test.xml @@ -0,0 +1,11 @@ + + + + %d{HH:mm:ss.SSS} %-5level - %msg%n + + + + + + + diff --git a/src/main/resources/logback.xml b/src/main/resources/logback.xml new file mode 100644 index 0000000..5c07584 --- /dev/null +++ b/src/main/resources/logback.xml @@ -0,0 +1,11 @@ + + + + + + + + %d{HH:mm:ss.SSS} %-5level - %msg%n + + + diff --git a/src/test/java/com/eppo/sdk/EppoClientTest.java b/src/test/java/com/eppo/sdk/EppoClientTest.java index c9c136f..246a3a5 100644 --- a/src/test/java/com/eppo/sdk/EppoClientTest.java +++ b/src/test/java/com/eppo/sdk/EppoClientTest.java @@ -10,8 +10,7 @@ import java.io.File; import java.io.IOException; -import java.util.ArrayList; -import java.util.List; +import java.util.*; import java.util.stream.Collectors; import java.util.stream.Stream; @@ -31,12 +30,15 @@ import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.provider.Arguments; import org.junit.jupiter.params.provider.MethodSource; -import java.util.Optional; import com.github.tomakehurst.wiremock.WireMockServer; import com.github.tomakehurst.wiremock.client.WireMock; import com.github.tomakehurst.wiremock.junit5.WireMockExtension; import lombok.Data; +import org.mockito.ArgumentCaptor; + +import static org.junit.jupiter.api.Assertions.*; +import static org.mockito.Mockito.*; @ExtendWith(WireMockExtension.class) public class EppoClientTest { @@ -44,7 +46,6 @@ public class EppoClientTest { private static final int TEST_PORT = 4001; private WireMockServer mockServer; - private static ObjectMapper MAPPER = new ObjectMapper(); static { MAPPER.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); @@ -53,7 +54,7 @@ public class EppoClientTest { @Data static class SubjectWithAttributes { String subjectKey; - SubjectAttributes subjectAttributes; + EppoAttributes subjectAttributes; } @Data @@ -122,28 +123,35 @@ public AssignmentValueType deserialize(JsonParser jsonParser, DeserializationCon } } + private IAssignmentLogger mockAssignmentLogger; + private IBanditLogger mockBanditLogger; + @BeforeEach void init() { - setupMockRacServer(); - EppoClientConfig config = EppoClientConfig.builder() - .apiKey("mock-api-key") - .baseURL("http://localhost:4001") - .assignmentLogger(new IAssignmentLogger() { - @Override - public void logAssignment(AssignmentLogData logData) { - // Auto-generated method stub - } - }) - .build(); - EppoClient.init(config); - } + mockAssignmentLogger = mock(IAssignmentLogger.class); + mockBanditLogger = mock(IBanditLogger.class); - private void setupMockRacServer() { + // For now, use our special bandits RAC until we fold it into the shared test case suite this.mockServer = new WireMockServer(TEST_PORT); this.mockServer.start(); - String racResponseJson = getMockRandomizedAssignmentResponse(); + String racResponseJson = getMockRandomizedAssignmentResponse("src/test/resources/bandits/rac-experiments-bandits-beta.json"); this.mockServer.stubFor( - WireMock.get(WireMock.urlMatching(".*randomized_assignment.*")).willReturn(WireMock.okJson(racResponseJson))); + WireMock.get(WireMock.urlMatching(".*randomized_assignment/v3/config\\?.*")).willReturn(WireMock.okJson(racResponseJson)) + ); + String banditResponseJson = getMockRandomizedAssignmentResponse("src/test/resources/bandits/bandits-parameters-1.json"); + this.mockServer.stubFor( + WireMock.get(WireMock.urlMatching(".*randomized_assignment/v3/bandits\\?.*")) + .willReturn(WireMock.okJson(banditResponseJson)) + ); + + // Initialize our client with the mock loggers we can spy on + EppoClientConfig config = EppoClientConfig.builder() + .apiKey("mock-api-key") + .baseURL("http://localhost:4001") + .assignmentLogger(mockAssignmentLogger) + .banditLogger(mockBanditLogger) + .build(); + EppoClient.init(config); } @AfterEach @@ -169,9 +177,14 @@ public void logAssignment(AssignmentLogData logData) { EppoClient spyClient = spy(realClient); - doThrow(new ExperimentConfigurationNotFound("Exception thrown by mock")).when(spyClient) - .getAssignmentValue(anyString(), - anyString(), any(SubjectAttributes.class)); + doThrow(new ExperimentConfigurationNotFound("Exception thrown by mock")) + .when(spyClient) + .getAssignmentValue( + anyString(), + anyString(), + any(EppoAttributes.class), + anyMapOf(String.class, EppoAttributes.class) + ); assertDoesNotThrow(() -> spyClient.getBooleanAssignment("subject1", "experiment1")); assertDoesNotThrow(() -> spyClient.getDoubleAssignment("subject1", "experiment1")); @@ -204,9 +217,13 @@ public void logAssignment(AssignmentLogData logData) { EppoClient spyClient = spy(realClient); - doThrow(new ExperimentConfigurationNotFound("Exception thrown by mock")).when(spyClient).getAssignmentValue( - anyString(), - anyString(), any(SubjectAttributes.class)); + doThrow(new ExperimentConfigurationNotFound("Exception thrown by mock")) + .when(spyClient).getAssignmentValue( + anyString(), + anyString(), + any(EppoAttributes.class), + anyMapOf(String.class, EppoAttributes.class) + ); assertThrows(ExperimentConfigurationNotFound.class, () -> spyClient.getBooleanAssignment("subject1", "experiment1")); @@ -220,7 +237,20 @@ public void logAssignment(AssignmentLogData logData) { @ParameterizedTest @MethodSource("getAssignmentTestData") - void testAssignments(AssignmentTestCase testCase) throws IOException { + void testAssignments(AssignmentTestCase testCase) { + + // These test cases rely on the currently shared non-bandit RAC, so we need to re-initialize our client to use that + String racResponseJson = getMockRandomizedAssignmentResponse("src/test/resources/rac-experiments-v3.json"); + this.mockServer.stubFor( + WireMock.get(WireMock.urlMatching(".*randomized_assignment/v3/config\\?.*")).willReturn(WireMock.okJson(racResponseJson)) + ); + EppoClientConfig config = EppoClientConfig.builder() + .apiKey("mock-api-key") + .baseURL("http://localhost:4001") + .assignmentLogger(mockAssignmentLogger) + .build(); + EppoClient.init(config); + switch (testCase.valueType) { case NUMERIC: List expectedDoubleAssignments = Converter.convertToDecimal(testCase.expectedAssignments); @@ -326,12 +356,338 @@ private static Stream getAssignmentTestData() throws IOException { return arguments.stream(); } - private static String getMockRandomizedAssignmentResponse() { - File mockRacResponse = new File("src/test/resources/rac-experiments-v3.json"); + private static String getMockRandomizedAssignmentResponse(String jsonToReturnFilePath) { + File mockRacResponse = new File(jsonToReturnFilePath); try { return FileUtils.readFileToString(mockRacResponse, "UTF8"); } catch (Exception e) { - throw new RuntimeException("Error reading mock RAC data", e); + throw new RuntimeException("Error reading mock RAC data: "+e.getMessage(), e); } } + + @Test + public void testBanditColdStartAction() { + Set banditActions = Stream.of("option1", "option2", "option3").collect(Collectors.toSet()); + + // Attempt to get a bandit assignment + Optional stringAssignment = EppoClient.getInstance().getStringAssignment( + "subject1", + "cold-start-bandit-experiment", + new EppoAttributes(), + banditActions + ); + + // Verify assignment + assertTrue(stringAssignment.isPresent()); + assertTrue(banditActions.contains(stringAssignment.get())); + + // Verify experiment assignment log + ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(AssignmentLogData.class); + verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); + AssignmentLogData capturedAssignmentLog = assignmentLogCaptor.getValue(); + assertEquals("cold-start-bandit-experiment-bandit", capturedAssignmentLog.experiment); + assertEquals("cold-start-bandit-experiment", capturedAssignmentLog.featureFlag); + assertEquals("bandit", capturedAssignmentLog.allocation); + assertEquals("cold-start-bandit", capturedAssignmentLog.variation); + assertEquals("subject1", capturedAssignmentLog.subject); + assertEquals(new EppoAttributes(), capturedAssignmentLog.subjectAttributes); + + // Verify bandit log + ArgumentCaptor banditLogCaptor = ArgumentCaptor.forClass(BanditLogData.class); + verify(mockBanditLogger, times(1)).logBanditAction(banditLogCaptor.capture()); + BanditLogData capturedBanditLog = banditLogCaptor.getValue(); + assertEquals("cold-start-bandit-experiment-bandit", capturedBanditLog.experiment); + assertEquals("cold-start-bandit", capturedBanditLog.banditKey); + assertEquals("subject1", capturedBanditLog.subject); + assertEquals(new HashMap<>(), capturedBanditLog.subjectNumericAttributes); + assertEquals(new HashMap<>(), capturedBanditLog.subjectCategoricalAttributes); + assertEquals("option1", capturedBanditLog.action); + assertEquals(new HashMap<>(), capturedBanditLog.actionNumericAttributes); + assertEquals(new HashMap<>(), capturedBanditLog.actionCategoricalAttributes); + assertEquals(0.3333, capturedBanditLog.actionProbability, 0.0002); + assertEquals("falcon cold start", capturedBanditLog.modelVersion); + } + + @Test + public void testBanditUninitializedAction() { + Set banditActions = Stream.of("option1", "option2", "option3").collect(Collectors.toSet()); + + // Attempt to get a bandit assignment + Optional stringAssignment = EppoClient.getInstance().getStringAssignment( + "subject8", + "uninitialized-bandit-experiment", + new EppoAttributes(), + banditActions + ); + + // Verify assignment + assertTrue(stringAssignment.isPresent()); + assertTrue(banditActions.contains(stringAssignment.get())); + + // Verify experiment assignment log + ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(AssignmentLogData.class); + verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); + AssignmentLogData capturedAssignmentLog = assignmentLogCaptor.getValue(); + assertEquals("uninitialized-bandit-experiment-bandit", capturedAssignmentLog.experiment); + assertEquals("uninitialized-bandit-experiment", capturedAssignmentLog.featureFlag); + assertEquals("bandit", capturedAssignmentLog.allocation); + assertEquals("this-bandit-does-not-exist", capturedAssignmentLog.variation); + assertEquals("subject8", capturedAssignmentLog.subject); + assertEquals(new EppoAttributes(), capturedAssignmentLog.subjectAttributes); + + // Verify bandit log + ArgumentCaptor banditLogCaptor = ArgumentCaptor.forClass(BanditLogData.class); + verify(mockBanditLogger, times(1)).logBanditAction(banditLogCaptor.capture()); + BanditLogData capturedBanditLog = banditLogCaptor.getValue(); + assertEquals("uninitialized-bandit-experiment-bandit", capturedBanditLog.experiment); + assertEquals("this-bandit-does-not-exist", capturedBanditLog.banditKey); + assertEquals("subject8", capturedBanditLog.subject); + assertEquals(new HashMap<>(), capturedBanditLog.subjectNumericAttributes); + assertEquals(new HashMap<>(), capturedBanditLog.subjectCategoricalAttributes); + assertEquals("option1", capturedBanditLog.action); + assertEquals(new HashMap<>(), capturedBanditLog.actionNumericAttributes); + assertEquals(new HashMap<>(), capturedBanditLog.actionCategoricalAttributes); + assertEquals(0.3333, capturedBanditLog.actionProbability, 0.0002); + assertEquals("uninitialized", capturedBanditLog.modelVersion); + } + + @Test + public void testBanditModelActionLogging() { + // Note: some of the passed in attributes are not used for scoring, but we do still want to make sure they are logged + + EppoAttributes subjectAttributes = new EppoAttributes(); + subjectAttributes.put("gender_identity", EppoValue.valueOf("female")); + subjectAttributes.put("days_since_signup", EppoValue.valueOf(130)); // unused for scoring (which looks for account_age) + subjectAttributes.put("is_premium", EppoValue.valueOf(false)); // unused for scoring + subjectAttributes.put("numeric_string", EppoValue.valueOf("123")); // unused for scoring + subjectAttributes.put("unpopulated", EppoValue.nullValue()); // unused for scoring + + Map actionsWithAttributes = new HashMap<>(); + + EppoAttributes nikeAttributes = new EppoAttributes(); + nikeAttributes.put("brand_affinity", EppoValue.valueOf(0.25)); + actionsWithAttributes.put("nike", nikeAttributes); + + EppoAttributes adidasAttributes = new EppoAttributes(); + adidasAttributes.put("brand_affinity", EppoValue.valueOf(0.1)); + adidasAttributes.put("num_brand_purchases", EppoValue.valueOf(5)); // unused for scoring + adidasAttributes.put("in_email_campaign", EppoValue.valueOf(true)); // unused for scoring + adidasAttributes.put("also_unpopulated", EppoValue.nullValue()); // unused for scoring + actionsWithAttributes.put("adidas", adidasAttributes); + + actionsWithAttributes.put("puma", new EppoAttributes()); + + // Get our assigned action + Optional stringAssignment = EppoClient.getInstance().getStringAssignment( + "subject2", + "banner-bandit-experiment", + subjectAttributes, + actionsWithAttributes + ); + + // Verify assignment + assertTrue(stringAssignment.isPresent()); + assertEquals("adidas", stringAssignment.get()); + + // Verify experiment assignment log + ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(AssignmentLogData.class); + verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); + AssignmentLogData capturedAssignmentLog = assignmentLogCaptor.getValue(); + assertEquals("banner-bandit-experiment-bandit", capturedAssignmentLog.experiment); + assertEquals("banner-bandit-experiment", capturedAssignmentLog.featureFlag); + assertEquals("bandit", capturedAssignmentLog.allocation); + assertEquals("banner-bandit", capturedAssignmentLog.variation); + assertEquals("subject2", capturedAssignmentLog.subject); + assertEquals(subjectAttributes, capturedAssignmentLog.subjectAttributes); + + // Verify bandit log + ArgumentCaptor banditLogCaptor = ArgumentCaptor.forClass(BanditLogData.class); + verify(mockBanditLogger, times(1)).logBanditAction(banditLogCaptor.capture()); + BanditLogData capturedBanditLog = banditLogCaptor.getValue(); + assertEquals("banner-bandit-experiment-bandit", capturedBanditLog.experiment); + assertEquals("banner-bandit", capturedBanditLog.banditKey); + assertEquals("subject2", capturedBanditLog.subject); + assertEquals("adidas", capturedBanditLog.action); + assertEquals(0.2899, capturedBanditLog.actionProbability, 0.0002); + assertEquals("falcon v123", capturedBanditLog.modelVersion); + + Map expectedSubjectNumericAttributes = new HashMap<>(); + expectedSubjectNumericAttributes.put("days_since_signup", 130.0); + assertEquals(expectedSubjectNumericAttributes, capturedBanditLog.subjectNumericAttributes); + + Map expectedSubjectCategoricalAttributes = new HashMap<>(); + expectedSubjectCategoricalAttributes.put("gender_identity", "female"); + expectedSubjectCategoricalAttributes.put("is_premium", "false"); + expectedSubjectCategoricalAttributes.put("numeric_string", "123"); + assertEquals(expectedSubjectCategoricalAttributes, capturedBanditLog.subjectCategoricalAttributes); + + Map expectedActionNumericAttributes = new HashMap<>(); + expectedActionNumericAttributes.put("brand_affinity", 0.1); + expectedActionNumericAttributes.put("num_brand_purchases", 5.0); + assertEquals(expectedActionNumericAttributes, capturedBanditLog.actionNumericAttributes); + + Map expectedActionCategoricalAttributes = new HashMap<>(); + expectedActionCategoricalAttributes.put("in_email_campaign", "true"); + assertEquals(expectedActionCategoricalAttributes, capturedBanditLog.actionCategoricalAttributes); + } + + @Test + public void testBanditModelActionAssignmentFullContext() { + EppoAttributes subjectAttributes = new EppoAttributes(); + subjectAttributes.put("gender_identity", EppoValue.valueOf("male")); + subjectAttributes.put("account_age", EppoValue.valueOf(3)); + + Map actionAttributes = new HashMap<>(); + + EppoAttributes nikeAttributes = new EppoAttributes(); + nikeAttributes.put("brand_affinity", EppoValue.valueOf(0.05)); + nikeAttributes.put("purchased_last_30_days", EppoValue.valueOf(true)); + nikeAttributes.put("loyalty_tier", EppoValue.valueOf("gold")); + actionAttributes.put("nike", nikeAttributes); + + EppoAttributes adidasAttributes = new EppoAttributes(); + adidasAttributes.put("brand_affinity", EppoValue.valueOf(0.30)); + adidasAttributes.put("purchased_last_30_days", EppoValue.valueOf(true)); + adidasAttributes.put("loyalty_tier", EppoValue.valueOf("gold")); + actionAttributes.put("adidas", adidasAttributes); + + EppoAttributes pumaAttributes = new EppoAttributes(); + pumaAttributes.put("brand_affinity", EppoValue.valueOf(1.00)); + pumaAttributes.put("purchased_last_30_days", EppoValue.valueOf(false)); + pumaAttributes.put("loyalty_tier", EppoValue.valueOf("bronze")); + actionAttributes.put("puma", pumaAttributes); + + // Get our assigned action + Optional stringAssignment = EppoClient.getInstance().getStringAssignment( + "subject30", + "banner-bandit-experiment", + subjectAttributes, + actionAttributes + ); + + // Verify assignment + assertTrue(stringAssignment.isPresent()); + assertEquals("adidas", stringAssignment.get()); + ArgumentCaptor banditLogCaptor = ArgumentCaptor.forClass(BanditLogData.class); + verify(mockBanditLogger, times(1)).logBanditAction(banditLogCaptor.capture()); + BanditLogData capturedBanditLog = banditLogCaptor.getValue(); + assertEquals(0.8043, capturedBanditLog.actionProbability, 0.0002); + } + + @Test + public void testBanditModelActionAssignmentNoContext() { + EppoAttributes subjectAttributes = new EppoAttributes(); + Set actions = Stream.of("nike", "adidas", "puma").collect(Collectors.toSet()); + + // Get our assigned action + Optional stringAssignment = EppoClient.getInstance().getStringAssignment( + "subject39", + "banner-bandit-experiment", + subjectAttributes, + actions + ); + + // Verify assignment + assertTrue(stringAssignment.isPresent()); + assertEquals("puma", stringAssignment.get()); + ArgumentCaptor banditLogCaptor = ArgumentCaptor.forClass(BanditLogData.class); + verify(mockBanditLogger, times(1)).logBanditAction(banditLogCaptor.capture()); + BanditLogData capturedBanditLog = banditLogCaptor.getValue(); + assertEquals(0.1613, capturedBanditLog.actionProbability, 0.0002); + } + + @Test + public void testBanditControlAction() { + + Set banditActions = Stream.of("option1", "option2", "option3").collect(Collectors.toSet()); + + EppoAttributes subjectAttributes = new EppoAttributes(); + subjectAttributes.put("account_age", EppoValue.valueOf(90)); + subjectAttributes.put("loyalty_tier", EppoValue.valueOf("gold")); + subjectAttributes.put("is_account_admin", EppoValue.valueOf(false)); + + // Attempt to get a bandit assignment + Optional stringAssignment = EppoClient.getInstance().getStringAssignment( + "subject10", + "cold-start-bandit-experiment", + subjectAttributes, + banditActions + ); + + // Verify assignment + assertTrue(stringAssignment.isPresent()); + assertEquals("control", stringAssignment.get()); + + // Manually log an action + + EppoAttributes controlActionAttributes = new EppoAttributes(); + controlActionAttributes.put("brand", EppoValue.valueOf("skechers")); + controlActionAttributes.put("num_past_purchases", EppoValue.valueOf(0)); + controlActionAttributes.put("has_promo_code", EppoValue.valueOf(true)); + + Exception banditLoggingException = EppoClient.getInstance().logNonBanditAction( + "subject10", + "cold-start-bandit-experiment", + subjectAttributes, + "option0", + controlActionAttributes + ); + assertNull(banditLoggingException); + + // Verify experiment assignment log + ArgumentCaptor assignmentLogCaptor = ArgumentCaptor.forClass(AssignmentLogData.class); + verify(mockAssignmentLogger, times(1)).logAssignment(assignmentLogCaptor.capture()); + AssignmentLogData capturedAssignmentLog = assignmentLogCaptor.getValue(); + assertEquals("cold-start-bandit-experiment-bandit", capturedAssignmentLog.experiment); + assertEquals("cold-start-bandit-experiment", capturedAssignmentLog.featureFlag); + assertEquals("bandit", capturedAssignmentLog.allocation); + assertEquals("control", capturedAssignmentLog.variation); + assertEquals("subject10", capturedAssignmentLog.subject); + assertEquals(subjectAttributes, capturedAssignmentLog.subjectAttributes); + + // Verify bandit log + ArgumentCaptor banditLogCaptor = ArgumentCaptor.forClass(BanditLogData.class); + verify(mockBanditLogger, times(1)).logBanditAction(banditLogCaptor.capture()); + BanditLogData capturedBanditLog = banditLogCaptor.getValue(); + assertEquals("cold-start-bandit-experiment-bandit", capturedBanditLog.experiment); + assertEquals("control", capturedBanditLog.banditKey); + assertEquals("subject10", capturedBanditLog.subject); + assertEquals("option0", capturedBanditLog.action); + assertNull(capturedBanditLog.actionProbability); + assertNull(capturedBanditLog.modelVersion); + + Map expectedSubjectNumericAttributes = new HashMap<>(); + expectedSubjectNumericAttributes.put("account_age", 90.0); + assertEquals(expectedSubjectNumericAttributes, capturedBanditLog.subjectNumericAttributes); + + Map expectedSubjectCategoricalAttributes = new HashMap<>(); + expectedSubjectCategoricalAttributes.put("loyalty_tier", "gold"); + expectedSubjectCategoricalAttributes.put("is_account_admin", "false"); + assertEquals(expectedSubjectCategoricalAttributes, capturedBanditLog.subjectCategoricalAttributes); + + Map expectedActionNumericAttributes = new HashMap<>(); + expectedActionNumericAttributes.put("num_past_purchases", 0.0); + assertEquals(expectedActionNumericAttributes, capturedBanditLog.actionNumericAttributes); + + Map expectedActionCategoricalAttributes = new HashMap<>(); + expectedActionCategoricalAttributes.put("brand", "skechers"); + expectedActionCategoricalAttributes.put("has_promo_code", "true"); + assertEquals(expectedActionCategoricalAttributes, capturedBanditLog.actionCategoricalAttributes); + } + + @Test + public void testBanditNotInAllocation() { + Set banditActions = Stream.of("option1", "option2", "option3").collect(Collectors.toSet()); + + // Attempt to get a bandit assignment + Optional stringAssignment = EppoClient.getInstance().getStringAssignment( + "subject2", + "cold-start-bandit", + new EppoAttributes(), + banditActions + ); + + // Verify assignment + assertFalse(stringAssignment.isPresent()); + } } diff --git a/src/test/java/com/eppo/sdk/deserializer/BanditsDeserializerTest.java b/src/test/java/com/eppo/sdk/deserializer/BanditsDeserializerTest.java new file mode 100644 index 0000000..e4f0860 --- /dev/null +++ b/src/test/java/com/eppo/sdk/deserializer/BanditsDeserializerTest.java @@ -0,0 +1,129 @@ +package com.eppo.sdk.deserializer; + +import com.eppo.sdk.dto.*; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.apache.commons.io.FileUtils; +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class BanditsDeserializerTest { + + private final ObjectMapper mapper = new ObjectMapper(); + + @Test + public void testDeserializingBandits() throws IOException { + String jsonString = FileUtils.readFileToString(new File("src/test/resources/bandits/bandits-parameters-1.json"), "UTF8"); + BanditParametersResponse responseObject = this.mapper.readValue(jsonString, BanditParametersResponse.class); + + assertEquals(2, responseObject.getBandits().size()); + BanditParameters parameters = responseObject.getBandits().get("banner-bandit"); + assertEquals("banner-bandit", parameters.getBanditKey()); + assertEquals("falcon", parameters.getModelName()); + assertEquals("v123", parameters.getModelVersion()); + + BanditModelData modelData = parameters.getModelData(); + assertEquals(1.0, modelData.getGamma()); + assertEquals(0.0, modelData.getDefaultActionScore()); + assertEquals(0.0, modelData.getActionProbabilityFloor()); + + Map coefficients = modelData.getCoefficients(); + assertEquals(2, coefficients.size()); + + // Nike + + BanditCoefficients nikeCoefficients = coefficients.get("nike"); + assertEquals("nike", nikeCoefficients.getActionKey()); + assertEquals(1.0, nikeCoefficients.getIntercept()); + + // Nike subject coefficients + + Map nikeSubjectNumericCoefficients = nikeCoefficients.getSubjectNumericCoefficients(); + assertEquals(1, nikeSubjectNumericCoefficients.size()); + + BanditNumericAttributeCoefficients nikeAccountAgeCoefficients = nikeSubjectNumericCoefficients.get("account_age"); + assertEquals("account_age", nikeAccountAgeCoefficients.getAttributeKey()); + assertEquals(0.3, nikeAccountAgeCoefficients.getCoefficient()); + assertEquals(0.0, nikeAccountAgeCoefficients.getMissingValueCoefficient()); + + Map nikeSubjectCategoricalCoefficients = nikeCoefficients.getSubjectCategoricalCoefficients(); + assertEquals(1, nikeSubjectCategoricalCoefficients.size()); + + BanditCategoricalAttributeCoefficients nikeGenderIdentityCoefficients = nikeSubjectCategoricalCoefficients.get("gender_identity"); + assertEquals("gender_identity", nikeGenderIdentityCoefficients.getAttributeKey()); + assertEquals(2.3, nikeGenderIdentityCoefficients.getMissingValueCoefficient()); + Map nikeGenderIdentityCoefficientValues = nikeGenderIdentityCoefficients.getValueCoefficients(); + assertEquals(2, nikeGenderIdentityCoefficientValues.size()); + assertEquals(0.5, nikeGenderIdentityCoefficientValues.get("female")); + assertEquals(-0.5, nikeGenderIdentityCoefficientValues.get("male")); + + // Nike action coefficients + + Map nikeActionNumericCoefficients = nikeCoefficients.getActionNumericCoefficients(); + assertEquals(1, nikeActionNumericCoefficients.size()); + + BanditNumericAttributeCoefficients nikeBrandAffinityCoefficient = nikeActionNumericCoefficients.get("brand_affinity"); + assertEquals("brand_affinity", nikeBrandAffinityCoefficient.getAttributeKey()); + assertEquals(1.0, nikeBrandAffinityCoefficient.getCoefficient()); + assertEquals(-0.1, nikeBrandAffinityCoefficient.getMissingValueCoefficient()); + + Map nikeActionCategoricalCoefficients = nikeCoefficients.getActionCategoricalCoefficients(); + assertEquals(1, nikeActionCategoricalCoefficients.size()); + + BanditCategoricalAttributeCoefficients nikeLoyaltyCoefficients = nikeActionCategoricalCoefficients.get("loyalty_tier"); + assertEquals("loyalty_tier", nikeLoyaltyCoefficients.getAttributeKey()); + assertEquals(0.0, nikeLoyaltyCoefficients.getMissingValueCoefficient()); + Map nikeLoyaltyCoefficientValues = nikeLoyaltyCoefficients.getValueCoefficients(); + assertEquals(3, nikeLoyaltyCoefficientValues.size()); + assertEquals(4.5, nikeLoyaltyCoefficientValues.get("gold")); + assertEquals(3.2, nikeLoyaltyCoefficientValues.get("silver")); + assertEquals(1.9, nikeLoyaltyCoefficientValues.get("bronze")); + + // Adidas + + BanditCoefficients adidasCoefficients = coefficients.get("adidas"); + assertEquals("adidas", adidasCoefficients.getActionKey()); + assertEquals(1.1, adidasCoefficients.getIntercept()); + + // Adidas subject coefficients + + Map adidasSubjectNumericCoefficients = adidasCoefficients.getSubjectNumericCoefficients(); + assertEquals(0, adidasSubjectNumericCoefficients.size()); + + Map adidasSubjectCategoricalCoefficients = adidasCoefficients.getSubjectCategoricalCoefficients(); + assertEquals(1, adidasSubjectCategoricalCoefficients.size()); + + BanditCategoricalAttributeCoefficients adidasGenderIdentityCoefficient = adidasSubjectCategoricalCoefficients.get("gender_identity"); + assertEquals("gender_identity", adidasGenderIdentityCoefficient.getAttributeKey()); + assertEquals(0.45, adidasGenderIdentityCoefficient.getMissingValueCoefficient()); + Map adidasGenderIdentityCoefficientValues = adidasGenderIdentityCoefficient.getValueCoefficients(); + assertEquals(2, adidasGenderIdentityCoefficientValues.size()); + assertEquals(0.0, adidasGenderIdentityCoefficientValues.get("female")); + assertEquals(0.3, adidasGenderIdentityCoefficientValues.get("male")); + + // Adidas action coefficients + + Map adidasActionNumericCoefficients = adidasCoefficients.getActionNumericCoefficients(); + assertEquals(1, nikeActionNumericCoefficients.size()); + + BanditNumericAttributeCoefficients adidasBrandAffinityCoefficient = adidasActionNumericCoefficients.get("brand_affinity"); + assertEquals("brand_affinity", adidasBrandAffinityCoefficient.getAttributeKey()); + assertEquals(2.0, adidasBrandAffinityCoefficient.getCoefficient()); + assertEquals(1.2, adidasBrandAffinityCoefficient.getMissingValueCoefficient()); + + Map adidasActionCategoricalCoefficients = adidasCoefficients.getActionCategoricalCoefficients(); + assertEquals(1, adidasActionCategoricalCoefficients.size()); + + BanditCategoricalAttributeCoefficients adidasPurchasedLast30Coefficient = adidasActionCategoricalCoefficients.get("purchased_last_30_days"); + assertEquals("purchased_last_30_days", adidasPurchasedLast30Coefficient.getAttributeKey()); + assertEquals(0.0, adidasPurchasedLast30Coefficient.getMissingValueCoefficient()); + Map adidasPurchasedLast30CoefficientValues = adidasPurchasedLast30Coefficient.getValueCoefficients(); + assertEquals(2, adidasPurchasedLast30CoefficientValues.size()); + assertEquals(9.0, adidasPurchasedLast30CoefficientValues.get("true")); + assertEquals(0.0, adidasPurchasedLast30CoefficientValues.get("false")); + } +} diff --git a/src/test/java/com/eppo/sdk/deserializer/EppoValueDeserializerTest.java b/src/test/java/com/eppo/sdk/deserializer/EppoValueDeserializerTest.java index 90db44f..906488e 100644 --- a/src/test/java/com/eppo/sdk/deserializer/EppoValueDeserializerTest.java +++ b/src/test/java/com/eppo/sdk/deserializer/EppoValueDeserializerTest.java @@ -57,4 +57,4 @@ void testDeserializingRandomObject() throws Exception { SingleEppoValue object = mapper.readValue("{\"value\": {\"test\" : \"test\"}}", SingleEppoValue.class); Assertions.assertTrue(object.value.jsonNodeValue().get("test").textValue().compareTo("test") == 0); } -} \ No newline at end of file +} diff --git a/src/test/java/com/eppo/sdk/dto/EppoAttributesTest.java b/src/test/java/com/eppo/sdk/dto/EppoAttributesTest.java new file mode 100644 index 0000000..f0e7f1a --- /dev/null +++ b/src/test/java/com/eppo/sdk/dto/EppoAttributesTest.java @@ -0,0 +1,70 @@ +package com.eppo.sdk.dto; + +import org.json.JSONException; +import org.junit.jupiter.api.Test; +import org.skyscreamer.jsonassert.JSONAssert; + +import java.util.HashMap; +import java.util.Map; + +class EppoAttributesTest { + + @Test + void testSerializeEppoAttributesToJSONString() throws JSONException { + EppoAttributes eppoAttributes = new EppoAttributes(); + eppoAttributes.put("boolean", EppoValue.valueOf(false)); + eppoAttributes.put("number", EppoValue.valueOf(1.234)); + eppoAttributes.put("string", EppoValue.valueOf("hello")); + eppoAttributes.put("null", EppoValue.nullValue()); + + String serializedJSONString = eppoAttributes.serializeToJSONString(); + String expectedJson = "{ \"boolean\": false, \"number\": 1.234, \"string\": \"hello\", \"null\": null }"; + + JSONAssert.assertEquals(expectedJson, serializedJSONString, true); + + // Try omitting nulls now + serializedJSONString = EppoAttributes.serializeNonNullAttributesToJSONString(eppoAttributes); + expectedJson = "{ \"boolean\": false, \"number\": 1.234, \"string\": \"hello\" }"; + + JSONAssert.assertEquals(expectedJson, serializedJSONString, true); + } + + @Test + void testSerializeNumericAttributesToJSONString() throws JSONException { + Map numericAttributes = new HashMap<>(); + numericAttributes.put("positive", 12.3); + numericAttributes.put("negative", -45.6); + numericAttributes.put("integer", 43.0); + numericAttributes.put("null", null); + + String serializedJSONString = EppoAttributes.serializeAttributesToJSONString(numericAttributes); + String expectedJson = "{ \"positive\": 12.3, \"negative\": -45.6, \"integer\": 43, \"null\": null }"; + + JSONAssert.assertEquals(expectedJson, serializedJSONString, true); + + // Try omitting nulls now + serializedJSONString = EppoAttributes.serializeNonNullAttributesToJSONString(numericAttributes); + expectedJson = "{ \"positive\": 12.3, \"negative\": -45.6, \"integer\": 43 }"; + + JSONAssert.assertEquals(expectedJson, serializedJSONString, true); + } + + @Test + void testSerializeCategoricalAttributesToJSONString() throws JSONException { + Map categoricalAttributes = new HashMap<>(); + categoricalAttributes.put("a", "apple"); + categoricalAttributes.put("b", "banana"); + categoricalAttributes.put("null", null); + + String serializedJSONString = EppoAttributes.serializeAttributesToJSONString(categoricalAttributes); + String expectedJson = "{ \"a\": \"apple\", \"b\": \"banana\", \"null\": null }"; + + JSONAssert.assertEquals(expectedJson, serializedJSONString, true); + + // Try omitting nulls now + serializedJSONString = EppoAttributes.serializeNonNullAttributesToJSONString(categoricalAttributes); + expectedJson = "{ \"a\": \"apple\", \"b\": \"banana\" }"; + + JSONAssert.assertEquals(expectedJson, serializedJSONString, true); + } +} diff --git a/src/test/java/com/eppo/sdk/helpers/AppDetailsTest.java b/src/test/java/com/eppo/sdk/helpers/AppDetailsTest.java new file mode 100644 index 0000000..342f92e --- /dev/null +++ b/src/test/java/com/eppo/sdk/helpers/AppDetailsTest.java @@ -0,0 +1,50 @@ +package com.eppo.sdk.helpers; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; + +import java.lang.reflect.Field; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class AppDetailsTest { + + @BeforeEach + public void nullOutInstanceToReset() { + try { + Class appDetailsClass = Class.forName("com.eppo.sdk.helpers.AppDetails"); + Field instanceField = appDetailsClass.getDeclaredField("instance"); + instanceField.setAccessible(true); + instanceField.set(null, null); + } catch (ClassNotFoundException | NoSuchFieldException | IllegalAccessException e) { + throw new RuntimeException(e); + } + } + + @Test + public void testReadAppProperties() { + AppDetails appDetails = AppDetails.getInstance(); + assertEquals("java-server-sdk", appDetails.getName()); + assertTrue(appDetails.getVersion().matches("^\\d+\\.\\d+\\.\\d+")); + } + + @Test + public void testAppPropertyReadFailure() { + ClassLoader mockClassloader = Mockito.mock(ClassLoader.class); + Mockito.when(mockClassloader.getResourceAsStream("app.properties")).thenReturn(null); + + ClassLoader originalClassLoader = Thread.currentThread().getContextClassLoader(); + try { + Thread.currentThread().setContextClassLoader(mockClassloader); + AppDetails.getInstance(); // Initialize with mock class loader + } finally { + Thread.currentThread().setContextClassLoader(originalClassLoader); + } + + AppDetails appDetails = AppDetails.getInstance(); + assertEquals("java-server-sdk", appDetails.getName()); + assertEquals("1.0.0", appDetails.getVersion()); + } +} diff --git a/src/test/java/com/eppo/sdk/helpers/ConfigurationStoreTest.java b/src/test/java/com/eppo/sdk/helpers/ConfigurationStoreTest.java index 9633b6d..8016fdf 100644 --- a/src/test/java/com/eppo/sdk/helpers/ConfigurationStoreTest.java +++ b/src/test/java/com/eppo/sdk/helpers/ConfigurationStoreTest.java @@ -11,9 +11,15 @@ class ConfigurationStoreTest { ConfigurationStore createConfigurationStore( Cache experimentConfigurationCache, - ExperimentConfigurationRequestor requestor + ConfigurationRequestor requestor ) { - return new ConfigurationStore(experimentConfigurationCache, requestor); + return new ConfigurationStore( + experimentConfigurationCache, + requestor, + // This test doesn't check bandit cache + null, + null + ); } Cache createExperimentConfigurationCache(int maxEntries) { @@ -26,11 +32,11 @@ Cache createExperimentConfigurationCache(int ma @Test() void testSetExperimentConfiguration() { Cache cache = createExperimentConfigurationCache(10); - ExperimentConfigurationRequestor requestor = Mockito.mock(ExperimentConfigurationRequestor.class); + ConfigurationRequestor requestor = Mockito.mock(ConfigurationRequestor.class); ConfigurationStore store = createConfigurationStore(cache, requestor); store.setExperimentConfiguration("key1", new ExperimentConfiguration()); Assertions.assertInstanceOf(ExperimentConfiguration.class, store.getExperimentConfiguration("key1")); } -} \ No newline at end of file +} diff --git a/src/test/java/com/eppo/sdk/helpers/RuleValidatorTest.java b/src/test/java/com/eppo/sdk/helpers/RuleValidatorTest.java index 2057283..ea4dff0 100644 --- a/src/test/java/com/eppo/sdk/helpers/RuleValidatorTest.java +++ b/src/test/java/com/eppo/sdk/helpers/RuleValidatorTest.java @@ -83,11 +83,11 @@ public void addNotOneOfCondition(Rule rule) { addConditionToRule(rule, condition); } - public void addNameToSubjectAttribute(SubjectAttributes subjectAttributes) { + public void addNameToSubjectAttribute(EppoAttributes subjectAttributes) { subjectAttributes.put("name", EppoValue.valueOf("test")); } - public void addPriceToSubjectAttribute(SubjectAttributes subjectAttributes) { + public void addPriceToSubjectAttribute(EppoAttributes subjectAttributes) { subjectAttributes.put("price", EppoValue.valueOf("30")); } @@ -97,7 +97,7 @@ void testMatchesAnyRuleWithEmptyConditions() { List rules = new ArrayList<>(); final Rule ruleWithEmptyConditions = createRule(new ArrayList<>()); rules.add(ruleWithEmptyConditions); - SubjectAttributes subjectAttributes = new SubjectAttributes(); + EppoAttributes subjectAttributes = new EppoAttributes(); addNameToSubjectAttribute(subjectAttributes); Assertions.assertEquals(ruleWithEmptyConditions, @@ -108,7 +108,7 @@ void testMatchesAnyRuleWithEmptyConditions() { @Test void testMatchesAnyRuleWithEmptyRules() { List rules = new ArrayList<>(); - SubjectAttributes subjectAttributes = new SubjectAttributes(); + EppoAttributes subjectAttributes = new EppoAttributes(); addNameToSubjectAttribute(subjectAttributes); Assertions.assertFalse(RuleValidator.findMatchingRule(subjectAttributes, rules).isPresent()); @@ -122,7 +122,7 @@ void testMatchesAnyRuleWhenNoRuleMatches() { addNumericConditionToRule(rule); rules.add(rule); - SubjectAttributes subjectAttributes = new SubjectAttributes(); + EppoAttributes subjectAttributes = new EppoAttributes(); addPriceToSubjectAttribute(subjectAttributes); Assertions.assertFalse(RuleValidator.findMatchingRule(subjectAttributes, rules).isPresent()); @@ -136,7 +136,7 @@ void testMatchesAnyRuleWhenRuleMatches() { addNumericConditionToRule(rule); rules.add(rule); - SubjectAttributes subjectAttributes = new SubjectAttributes(); + EppoAttributes subjectAttributes = new EppoAttributes(); subjectAttributes.put("price", EppoValue.valueOf(15)); Assertions.assertEquals(rule, RuleValidator.findMatchingRule(subjectAttributes, rules).get()); @@ -150,7 +150,7 @@ void testMatchesAnyRuleWhenRuleMatchesWithSemVer() { addSemVerConditionToRule(rule); rules.add(rule); - SubjectAttributes subjectAttributes = new SubjectAttributes(); + EppoAttributes subjectAttributes = new EppoAttributes(); subjectAttributes.put("version", EppoValue.valueOf("1.15.5")); Assertions.assertEquals(rule, RuleValidator.findMatchingRule(subjectAttributes, rules).get()); @@ -164,7 +164,7 @@ void testMatchesAnyRuleWhenThrowInvalidSubjectAttribute() { addNumericConditionToRule(rule); rules.add(rule); - SubjectAttributes subjectAttributes = new SubjectAttributes(); + EppoAttributes subjectAttributes = new EppoAttributes(); subjectAttributes.put("price", EppoValue.valueOf("abcd")); Assertions.assertFalse(RuleValidator.findMatchingRule(subjectAttributes, rules).isPresent()); @@ -178,7 +178,7 @@ void testMatchesAnyRuleWithRegexCondition() { addRegexConditionToRule(rule); rules.add(rule); - SubjectAttributes subjectAttributes = new SubjectAttributes(); + EppoAttributes subjectAttributes = new EppoAttributes(); subjectAttributes.put("match", EppoValue.valueOf("abcd")); Assertions.assertEquals(rule, RuleValidator.findMatchingRule(subjectAttributes, rules).get()); @@ -192,7 +192,7 @@ void testMatchesAnyRuleWithRegexConditionNotMatched() { addRegexConditionToRule(rule); rules.add(rule); - SubjectAttributes subjectAttributes = new SubjectAttributes(); + EppoAttributes subjectAttributes = new EppoAttributes(); subjectAttributes.put("match", EppoValue.valueOf("123")); Assertions.assertFalse(RuleValidator.findMatchingRule(subjectAttributes, rules).isPresent()); @@ -206,7 +206,7 @@ void testMatchesAnyRuleWithNotOneOfRule() { addNotOneOfCondition(rule); rules.add(rule); - SubjectAttributes subjectAttributes = new SubjectAttributes(); + EppoAttributes subjectAttributes = new EppoAttributes(); subjectAttributes.put("oneOf", EppoValue.valueOf("value3")); Assertions.assertEquals(rule, RuleValidator.findMatchingRule(subjectAttributes, rules).get()); @@ -220,10 +220,10 @@ void testMatchesAnyRuleWithNotOneOfRuleNotPassed() { addNotOneOfCondition(rule); rules.add(rule); - SubjectAttributes subjectAttributes = new SubjectAttributes(); + EppoAttributes subjectAttributes = new EppoAttributes(); subjectAttributes.put("oneOf", EppoValue.valueOf("value1")); Assertions.assertFalse(RuleValidator.findMatchingRule(subjectAttributes, rules).isPresent()); } -} \ No newline at end of file +} diff --git a/src/test/resources/bandits/bandits-parameters-1.json b/src/test/resources/bandits/bandits-parameters-1.json new file mode 100644 index 0000000..99120d2 --- /dev/null +++ b/src/test/resources/bandits/bandits-parameters-1.json @@ -0,0 +1,101 @@ +{ + "updatedAt": "2023-09-13T04:52:06.462Z", + "bandits": [ + { + "banditKey": "banner-bandit", + "modelName": "falcon", + "updatedAt": "2023-09-13T04:52:06.462Z", + "modelVersion": "v123", + "modelData": { + "gamma": 1.0, + "defaultActionScore": 0.0, + "actionProbabilityFloor": 0.0, + "coefficients": [ + { + "actionKey": "nike", + "intercept": 1.0, + "actionNumericCoefficients": [ + { + "attributeKey": "brand_affinity", + "coefficient": 1.0, + "missingValueCoefficient": -0.1 + } + ], + "actionCategoricalCoefficients": [ + { + "attributeKey": "loyalty_tier", + "values": [ + { "value": "gold", "coefficient": 4.5 }, + { "value": "silver", "coefficient": 3.2 }, + { "value": "bronze", "coefficient": 1.9 } + ], + "missingValueCoefficient": 0.0 + } + ], + "subjectNumericCoefficients": [ + { + "attributeKey": "account_age", + "coefficient": 0.3, + "missingValueCoefficient": 0.0 + } + ], + "subjectCategoricalCoefficients": [ + { + "attributeKey": "gender_identity", + "values": [ + { "value": "female", "coefficient": 0.5 }, + { "value": "male", "coefficient": -0.5 } + ], + "missingValueCoefficient": 2.3 + } + ] + }, + { + "actionKey": "adidas", + "intercept": 1.1, + "actionNumericCoefficients": [ + { + "attributeKey": "brand_affinity", + "coefficient": 2.0, + "missingValueCoefficient": 1.2 + } + ], + "actionCategoricalCoefficients": [ + { + "attributeKey": "purchased_last_30_days", + "values": [ + { "value": "true", "coefficient": 9.0 }, + { "value": "false", "coefficient": 0.0 } + ], + "missingValueCoefficient": 0.0 + } + ], + "subjectNumericCoefficients": [], + "subjectCategoricalCoefficients": [ + { + "attributeKey": "gender_identity", + "values": [ + { "value": "female", "coefficient": 0.0 }, + { "value": "male", "coefficient": 0.3 } + ], + "missingValueCoefficient": 0.45 + } + ] + } + ] + } + }, + { + "banditKey": "cold-start-bandit", + "modelName": "falcon", + "updatedAt": "2023-09-13T04:52:06.462Z", + "modelVersion": "cold start", + "modelData": { + "gamma": 1.0, + "defaultActionScore": 0.0, + "actionProbabilityFloor": 0.0, + "coefficients": [] + } + } + ] +} diff --git a/src/test/resources/bandits/rac-experiments-bandits-beta.json b/src/test/resources/bandits/rac-experiments-bandits-beta.json new file mode 100644 index 0000000..5e1c281 --- /dev/null +++ b/src/test/resources/bandits/rac-experiments-bandits-beta.json @@ -0,0 +1,118 @@ +{ + "flags": { + "cold-start-bandit-experiment": { + "subjectShards": 10000, + "overrides": {}, + "typedOverrides": {}, + "enabled": true, + "rules": [ + { + "allocationKey": "bandit", + "conditions": [] + } + ], + "allocations": { + "bandit": { + "percentExposure": 1.0, + "variations": [ + { + "name": "control", + "value": "control", + "typedValue": "control", + "shardRange": { + "start": 0, + "end": 2000 + } + }, + { + "name": "bandit", + "value": "cold-start-bandit", + "typedValue": "cold-start-bandit", + "shardRange": { + "start": 2000, + "end": 10000 + }, + "algorithmType": "CONTEXTUAL_BANDIT" + } + ] + } + } + }, + "uninitialized-bandit-experiment": { + "subjectShards": 10000, + "overrides": {}, + "typedOverrides": {}, + "enabled": true, + "rules": [ + { + "allocationKey": "bandit", + "conditions": [] + } + ], + "allocations": { + "bandit": { + "percentExposure": 0.4533, + "variations": [ + { + "name": "control", + "value": "control", + "typedValue": "control", + "shardRange": { + "start": 0, + "end": 2000 + } + }, + { + "name": "bandit", + "value": "this-bandit-does-not-exist", + "typedValue": "this-bandit-does-not-exist", + "shardRange": { + "start": 2000, + "end": 10000 + }, + "algorithmType": "CONTEXTUAL_BANDIT" + } + ] + } + } + }, + "banner-bandit-experiment": { + "subjectShards": 10000, + "overrides": {}, + "typedOverrides": {}, + "enabled": true, + "rules": [ + { + "allocationKey": "bandit", + "conditions": [] + } + ], + "allocations": { + "bandit": { + "percentExposure": 1.0, + "variations": [ + { + "name": "control", + "value": "control", + "typedValue": "control", + "shardRange": { + "start": 0, + "end": 2000 + } + }, + { + "name": "bandit", + "value": "banner-bandit", + "typedValue": "banner-bandit", + "shardRange": { + "start": 2000, + "end": 10000 + }, + "algorithmType": "CONTEXTUAL_BANDIT" + } + ] + } + } + } + } +}