Skip to content

Commit 9905026

Browse files
update: implement CMAB traffic allocation in Bucketer and DecisionService
1 parent 53d754a commit 9905026

File tree

3 files changed

+189
-8
lines changed

3 files changed

+189
-8
lines changed

core-api/src/main/java/com/optimizely/ab/bucketing/Bucketer.java

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,49 @@ private DecisionResponse<Variation> bucketToVariation(@Nonnull ExperimentCore ex
128128
return new DecisionResponse(null, reasons);
129129
}
130130

131+
/**
132+
* Determines CMAB traffic allocation for a user based on hashed value from murmurhash3.
133+
* This method handles bucketing users into CMAB (Contextual Multi-Armed Bandit) experiments.
134+
*/
135+
@Nonnull
136+
private DecisionResponse<String> bucketToEntityForCmab(@Nonnull Experiment experiment,
137+
@Nonnull String bucketingId) {
138+
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
139+
140+
// "salt" the bucket id using the experiment id
141+
String experimentId = experiment.getId();
142+
String experimentKey = experiment.getKey();
143+
String combinedBucketId = bucketingId + experimentId;
144+
145+
// Handle CMAB traffic allocation
146+
TrafficAllocation cmabTrafficAllocation = new TrafficAllocation("$", experiment.getCmab().getTrafficAllocation());
147+
List<TrafficAllocation> trafficAllocations = java.util.Collections.singletonList(cmabTrafficAllocation);
148+
149+
String cmabMessage = reasons.addInfo("Using CMAB traffic allocation for experiment \"%s\".", experimentKey);
150+
logger.debug(cmabMessage);
151+
152+
int hashCode = MurmurHash3.murmurhash3_x86_32(combinedBucketId, 0, combinedBucketId.length(), MURMUR_HASH_SEED);
153+
int bucketValue = generateBucketValue(hashCode);
154+
logger.debug("Assigned bucket {} to user with bucketingId \"{}\" when bucketing to a variation.", bucketValue, bucketingId);
155+
156+
String bucketedEntityId = bucketToEntity(bucketValue, trafficAllocations);
157+
if (bucketedEntityId != null) {
158+
if ("$".equals(bucketedEntityId)) {
159+
String message = reasons.addInfo("User with bucketingId \"%s\" is bucketed into CMAB for experiment \"%s\".", bucketingId, experimentKey);
160+
logger.info(message);
161+
} else {
162+
// This shouldn't happen in CMAB since we only have "$" entity, but handle gracefully
163+
String message = reasons.addInfo("User with bucketingId \"%s\" is bucketed into entity \"%s\" for experiment \"%s\".", bucketingId, bucketedEntityId, experimentKey);
164+
logger.info(message);
165+
}
166+
} else {
167+
String message = reasons.addInfo("User with bucketingId \"%s\" is not bucketed into CMAB for experiment \"%s\".", bucketingId, experimentKey);
168+
logger.info(message);
169+
}
170+
171+
return new DecisionResponse<>(bucketedEntityId, reasons);
172+
}
173+
131174
/**
132175
* Assign a {@link Variation} of an {@link Experiment} to a user based on hashed value from murmurhash3.
133176
*
@@ -177,6 +220,54 @@ public DecisionResponse<Variation> bucket(@Nonnull ExperimentCore experiment,
177220
return new DecisionResponse<>(decisionResponse.getResult(), reasons);
178221
}
179222

223+
/**
224+
* Assign a user to CMAB traffic for an experiment based on hashed value from murmurhash3.
225+
* This method handles CMAB (Contextual Multi-Armed Bandit) traffic allocation.
226+
*
227+
* @param experiment The CMAB Experiment in which the user is to be bucketed.
228+
* @param bucketingId string A customer-assigned value used to create the key for the murmur hash.
229+
* @param projectConfig The current projectConfig
230+
* @return A {@link DecisionResponse} including the entity ID ("$" if bucketed to CMAB, null otherwise) and decision reasons
231+
*/
232+
@Nonnull
233+
public DecisionResponse<String> bucketForCmab(@Nonnull Experiment experiment,
234+
@Nonnull String bucketingId,
235+
@Nonnull ProjectConfig projectConfig) {
236+
237+
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
238+
239+
// ---------- Handle Group Logic (same as regular bucket method) ----------
240+
String groupId = experiment.getGroupId();
241+
if (!groupId.isEmpty()) {
242+
Group experimentGroup = projectConfig.getGroupIdMapping().get(groupId);
243+
244+
if (experimentGroup.getPolicy().equals(Group.RANDOM_POLICY)) {
245+
Experiment bucketedExperiment = bucketToExperiment(experimentGroup, bucketingId, projectConfig);
246+
if (bucketedExperiment == null) {
247+
String message = reasons.addInfo("User with bucketingId \"%s\" is not in any experiment of group %s.", bucketingId, experimentGroup.getId());
248+
logger.info(message);
249+
return new DecisionResponse<>(null, reasons);
250+
}
251+
252+
if (!bucketedExperiment.getId().equals(experiment.getId())) {
253+
String message = reasons.addInfo("User with bucketingId \"%s\" is not in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(),
254+
experimentGroup.getId());
255+
logger.info(message);
256+
return new DecisionResponse<>(null, reasons);
257+
}
258+
259+
String message = reasons.addInfo("User with bucketingId \"%s\" is in experiment \"%s\" of group %s.", bucketingId, experiment.getKey(),
260+
experimentGroup.getId());
261+
logger.info(message);
262+
}
263+
}
264+
265+
// ---------- Use CMAB-aware bucketToEntity ----------
266+
DecisionResponse<String> decisionResponse = bucketToEntityForCmab(experiment, bucketingId);
267+
reasons.merge(decisionResponse.getReasons());
268+
return new DecisionResponse<>(decisionResponse.getResult(), reasons);
269+
}
270+
180271
//======== Helper methods ========//
181272

182273
/**

core-api/src/main/java/com/optimizely/ab/bucketing/DecisionService.java

Lines changed: 82 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@
2626
import javax.annotation.Nonnull;
2727
import javax.annotation.Nullable;
2828

29+
import com.optimizely.ab.cmab.service.CmabDecision;
2930
import com.optimizely.ab.cmab.service.CmabService;
3031
import org.slf4j.Logger;
3132
import org.slf4j.LoggerFactory;
@@ -153,9 +154,25 @@ public DecisionResponse<Variation> getVariation(@Nonnull Experiment experiment,
153154
if (decisionMeetAudience.getResult()) {
154155
String bucketingId = getBucketingId(user.getUserId(), user.getAttributes());
155156

156-
decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig);
157-
reasons.merge(decisionVariation.getReasons());
158-
variation = decisionVariation.getResult();
157+
if (isCmabExperiment(experiment)) {
158+
DecisionResponse<CmabDecision> cmabDecision = getDecisionForCmabExperiment(projectConfig, experiment, user, bucketingId, options);
159+
reasons.merge(cmabDecision.getReasons());
160+
161+
if (cmabDecision.isError()) {
162+
return new DecisionResponse<>(null, reasons, true);
163+
}
164+
165+
CmabDecision cmabResult = cmabDecision.getResult();
166+
if (cmabResult != null) {
167+
String variationId = cmabResult.getVariationId();
168+
variation = experiment.getVariationIdToVariationMap().get(variationId);
169+
}
170+
} else {
171+
// Standard bucketing for non-CMAB experiments
172+
decisionVariation = bucketer.bucket(experiment, bucketingId, projectConfig);
173+
reasons.merge(decisionVariation.getReasons());
174+
variation = decisionVariation.getResult();
175+
}
159176

160177
if (variation != null) {
161178
if (userProfileTracker != null) {
@@ -863,4 +880,66 @@ DecisionResponse<AbstractMap.SimpleEntry> getVariationFromDeliveryRule(@Nonnull
863880
return new DecisionResponse(variationToSkipToEveryoneElsePair, reasons);
864881
}
865882

883+
/**
884+
* Retrieves a decision for a contextual multi-armed bandit (CMAB)
885+
* experiment.
886+
*
887+
* @param projectConfig Instance of ProjectConfig.
888+
* @param experiment The experiment object for which the decision is to be
889+
* made.
890+
* @param userContext The user context containing user id and attributes.
891+
* @param bucketingId The bucketing ID to use for traffic allocation.
892+
* @param options Optional list of decide options.
893+
* @return A CmabDecisionResult containing error status, result, and
894+
* reasons.
895+
*/
896+
private DecisionResponse<CmabDecision> getDecisionForCmabExperiment(@Nonnull ProjectConfig projectConfig,
897+
@Nonnull Experiment experiment,
898+
@Nonnull OptimizelyUserContext userContext,
899+
@Nonnull String bucketingId,
900+
@Nonnull List<OptimizelyDecideOption> options) {
901+
DecisionReasons reasons = DefaultDecisionReasons.newInstance();
902+
903+
// Check if user is in CMAB traffic allocation
904+
DecisionResponse<String> bucketResponse = bucketer.bucketForCmab(experiment, bucketingId, projectConfig);
905+
reasons.merge(bucketResponse.getReasons());
906+
907+
String bucketedEntityId = bucketResponse.getResult();
908+
909+
if (bucketedEntityId == null) {
910+
String message = String.format("User \"%s\" not in CMAB experiment \"%s\" due to traffic allocation.",
911+
userContext.getUserId(), experiment.getKey());
912+
logger.info(message);
913+
reasons.addInfo(message);
914+
915+
return new DecisionResponse<>(null, reasons);
916+
}
917+
918+
// User is in CMAB allocation, proceed to CMAB decision
919+
try {
920+
CmabDecision cmabDecision = cmabService.getDecision(projectConfig, userContext, experiment.getId(), options);
921+
922+
return new DecisionResponse<>(cmabDecision, reasons);
923+
} catch (Exception e) {
924+
String errorMessage = String.format("CMAB fetch failed for experiment \"%s\"", experiment.getKey());
925+
reasons.addInfo(errorMessage);
926+
logger.error("{} {}", errorMessage, e.getMessage());
927+
928+
return new DecisionResponse<>(null, reasons);
929+
}
930+
}
931+
932+
/**
933+
* Checks whether an experiment is a contextual multi-armed bandit (CMAB)
934+
* experiment.
935+
*
936+
* @param experiment The experiment to check
937+
* @return true if the experiment is a CMAB experiment, false otherwise
938+
*/
939+
private boolean isCmabExperiment(@Nonnull Experiment experiment) {
940+
if (cmabService == null){
941+
return false;
942+
}
943+
return experiment.getCmab() != null;
944+
}
866945
}

core-api/src/main/java/com/optimizely/ab/optimizelydecision/DecisionResponse.java

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -22,18 +22,24 @@
2222
public class DecisionResponse<T> {
2323
private T result;
2424
private DecisionReasons reasons;
25+
private boolean error;
2526

26-
public DecisionResponse(@Nullable T result, @Nonnull DecisionReasons reasons) {
27+
public DecisionResponse(@Nullable T result, @Nonnull DecisionReasons reasons, @Nonnull boolean error) {
2728
this.result = result;
2829
this.reasons = reasons;
30+
this.error = error;
31+
}
32+
33+
public DecisionResponse(@Nullable T result, @Nonnull DecisionReasons reasons) {
34+
this(result, reasons, false);
2935
}
3036

31-
public static <E> DecisionResponse responseNoReasons(@Nullable E result) {
32-
return new DecisionResponse(result, DefaultDecisionReasons.newInstance());
37+
public static <E> DecisionResponse<E> responseNoReasons(@Nullable E result) {
38+
return new DecisionResponse<>(result, DefaultDecisionReasons.newInstance(), false);
3339
}
3440

35-
public static DecisionResponse nullNoReasons() {
36-
return new DecisionResponse(null, DefaultDecisionReasons.newInstance());
41+
public static <E> DecisionResponse<E> nullNoReasons() {
42+
return new DecisionResponse<>(null, DefaultDecisionReasons.newInstance(), false);
3743
}
3844

3945
@Nullable
@@ -45,4 +51,9 @@ public T getResult() {
4551
public DecisionReasons getReasons() {
4652
return reasons;
4753
}
54+
55+
@Nonnull
56+
public boolean isError(){
57+
return error;
58+
}
4859
}

0 commit comments

Comments
 (0)