Skip to content
Open
178 changes: 136 additions & 42 deletions core-api/src/main/java/com/absmartly/sdk/Context.java
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,8 @@ private void queueExposure(final Assignment assignment) {
exposure.fullOn = assignment.fullOn;
exposure.custom = assignment.custom;
exposure.audienceMismatch = assignment.audienceMismatch;
exposure.heldOut = assignment.heldOut;
exposure.holdoutId = assignment.holdoutId;

try {
eventLock_.lock();
Expand Down Expand Up @@ -692,12 +694,28 @@ private void checkReady(final boolean expectNotClosed) {
}
}

private boolean experimentMatches(final Experiment experiment, final Assignment assignment) {
return experiment.id == assignment.id &&
experiment.unitType.equals(assignment.unitType) &&
experiment.iteration == assignment.iteration &&
experiment.fullOnVariant == assignment.fullOnVariant &&
Arrays.equals(experiment.trafficSplit, assignment.trafficSplit);
private boolean experimentMatches(final ContextExperiment experiment, final Assignment assignment) {
return experiment.data.id == assignment.id &&
experiment.data.unitType.equals(assignment.unitType) &&
experiment.data.iteration == assignment.iteration &&
experiment.data.fullOnVariant == assignment.fullOnVariant &&
Arrays.equals(experiment.data.trafficSplit, assignment.trafficSplit) &&
Arrays.equals(experiment.data.holdoutIds, assignment.holdoutIds) &&
Arrays.equals(experiment.holdouts, assignment.holdouts);
}

// A custom assignment can only take effect on the normal, traffic-eligible assignment path.
// When the cached variant was forced by a higher-precedence rule — a holdout, a full-on
// variant, traffic ineligibility, or a strict audience mismatch — the custom value can never
// equal that variant, so comparing the two would spuriously invalidate the cache and re-expose
// on every getTreatment call. Treat those forced assignments as cache-valid regardless of the
// custom assignment. (A held-out or forced assignment always has assigned=true except for the
// strict-mismatch and no-unit cases, where assigned stays false.)
private static boolean variantForcedRegardlessOfCustom(final Assignment assignment) {
return assignment.heldOut
|| assignment.fullOn
|| !assignment.eligible
|| !assignment.assigned;
}

private static class Assignment {
Expand All @@ -715,6 +733,10 @@ private static class Assignment {
boolean custom;

boolean audienceMismatch;
boolean heldOut;
int holdoutId;
int[] holdoutIds;
ExperimentHoldout[] holdouts;
Map<String, Object> variables = Collections.emptyMap();

final AtomicBoolean exposed = new AtomicBoolean(false);
Expand Down Expand Up @@ -742,8 +764,9 @@ private Assignment getAssignment(final String experimentName) {
// previously not-running experiment
return assignment;
}
} else if ((custom == null) || custom == assignment.variant) {
if (experimentMatches(experiment.data, assignment)) {
} else if ((custom == null) || variantForcedRegardlessOfCustom(assignment)
|| custom == assignment.variant) {
if (experimentMatches(experiment, assignment)) {
// assignment up-to-date
return assignment;
}
Expand Down Expand Up @@ -778,50 +801,82 @@ private Assignment getAssignment(final String experimentName) {
if (experiment != null) {
final String unitType = experiment.data.unitType;

if (experiment.data.audience != null && experiment.data.audience.length() > 0) {
final Map<String, Object> attrs = new HashMap<String, Object>(attributes_.size());
for (final Attribute attr : attributes_) {
attrs.put(attr.name, attr.value);
}

final AudienceMatcher.Result match = audienceMatcher_
.evaluate(experiment.data.audience, attrs);
if (match != null) {
assignment.audienceMismatch = !match.get();
}
}
// Share the experiment's holdout arrays by reference rather than copying: they are
// only read here (and via Arrays.equals in experimentMatches) and experiment data is
// treated as immutable once installed by setData, so the aliasing is safe.
assignment.holdoutIds = experiment.data.holdoutIds;
assignment.holdouts = experiment.holdouts;

if (experiment.data.audienceStrict && assignment.audienceMismatch) {
assignment.variant = 0;
} else if (experiment.data.fullOnVariant == 0) {
final String uid = units_.get(experiment.data.unitType);
if (experiment.holdouts != null && experiment.holdouts.length > 0) {
final String uid = units_.get(unitType);
if (uid != null) {
final byte[] unitHash = Context.this.getUnitHash(unitType, uid);

final VariantAssigner assigner = Context.this.getVariantAssigner(unitType,
unitHash);
final boolean eligible = assigner.assign(experiment.data.trafficSplit,
experiment.data.trafficSeedHi,
experiment.data.trafficSeedLo) == 1;
if (eligible) {
if (custom != null) {
assignment.variant = custom;
assignment.custom = true;
for (final ExperimentHoldout holdout : experiment.holdouts) {
if (Boolean.TRUE.equals(holdout.fullOn) && experiment.data.fullOnVariant == 0) {
// a full_on holdout only applies to full-on experiments; the
// collector skips it server-side for non-full-on experiments too.
continue;
}

if (assigner.assign(holdout.split, holdout.seedHi, holdout.seedLo) == 0) {
assignment.heldOut = true;
assignment.holdoutId = holdout.id;
assignment.variant = 0;
assignment.assigned = true;
break;
}
}
}
}

if (!assignment.heldOut) {
if (experiment.data.audience != null && experiment.data.audience.length() > 0) {
final Map<String, Object> attrs = new HashMap<String, Object>(attributes_.size());
for (final Attribute attr : attributes_) {
attrs.put(attr.name, attr.value);
}

final AudienceMatcher.Result match = audienceMatcher_
.evaluate(experiment.data.audience, attrs);
if (match != null) {
assignment.audienceMismatch = !match.get();
}
}

if (experiment.data.audienceStrict && assignment.audienceMismatch) {
assignment.variant = 0;
} else if (experiment.data.fullOnVariant == 0) {
final String uid = units_.get(unitType);
if (uid != null) {
final byte[] unitHash = Context.this.getUnitHash(unitType, uid);

final VariantAssigner assigner = Context.this.getVariantAssigner(unitType,
unitHash);
final boolean eligible = assigner.assign(experiment.data.trafficSplit,
experiment.data.trafficSeedHi,
experiment.data.trafficSeedLo) == 1;
if (eligible) {
if (custom != null) {
assignment.variant = custom;
assignment.custom = true;
} else {
assignment.variant = assigner.assign(experiment.data.split,
experiment.data.seedHi,
experiment.data.seedLo);
}
} else {
assignment.variant = assigner.assign(experiment.data.split,
experiment.data.seedHi,
experiment.data.seedLo);
assignment.eligible = false;
assignment.variant = 0;
}
} else {
assignment.eligible = false;
assignment.variant = 0;
assignment.assigned = true;
}
} else {
assignment.assigned = true;
assignment.variant = experiment.data.fullOnVariant;
assignment.fullOn = true;
}
} else {
assignment.assigned = true;
assignment.variant = experiment.data.fullOnVariant;
assignment.fullOn = true;
}

assignment.unitType = unitType;
Expand Down Expand Up @@ -944,6 +999,7 @@ private void clearRefreshTimer() {

private static class ContextExperiment {
Experiment data;
ExperimentHoldout[] holdouts;
List<Map<String, Object>> variables;
Map<String, ContextCustomFieldValue> customFieldValues;
}
Expand All @@ -953,13 +1009,51 @@ private static class ContextCustomFieldValue {
Object value;
}

private static ExperimentHoldout[] resolveHoldouts(final int[] holdoutIds,
final Map<Integer, ExperimentHoldout> holdoutIndex) {
if (holdoutIds == null || holdoutIds.length == 0) {
return null;
}

final List<ExperimentHoldout> resolved = new ArrayList<ExperimentHoldout>(holdoutIds.length);
for (final int holdoutId : holdoutIds) {
final ExperimentHoldout holdout = holdoutIndex.get(holdoutId);
if (holdout != null && holdout.split != null && holdout.split.length > 0) {
resolved.add(holdout);
}
}

if (resolved.isEmpty()) {
return null;
}

Collections.sort(resolved, new Comparator<ExperimentHoldout>() {
@Override
public int compare(ExperimentHoldout a, ExperimentHoldout b) {
return Integer.valueOf(a.id).compareTo(b.id);
}
});

return resolved.toArray(new ExperimentHoldout[0]);
}

private void setData(final ContextData data) {
final Map<String, ContextExperiment> index = new HashMap<String, ContextExperiment>();
final Map<String, List<ContextExperiment>> indexVariables = new HashMap<String, List<ContextExperiment>>();

final Map<Integer, ExperimentHoldout> holdoutIndex = new HashMap<Integer, ExperimentHoldout>();
if (data.holdouts != null) {
for (final ExperimentHoldout holdout : data.holdouts) {
if (holdout != null) {
holdoutIndex.put(holdout.id, holdout);
}
}
}

for (final Experiment experiment : data.experiments) {
final ContextExperiment contextExperiment = new ContextExperiment();
contextExperiment.data = experiment;
contextExperiment.holdouts = resolveHoldouts(experiment.holdoutIds, holdoutIndex);
contextExperiment.variables = new ArrayList<Map<String, Object>>(experiment.variants.length);

for (final ExperimentVariant variant : experiment.variants) {
Expand Down
14 changes: 12 additions & 2 deletions core-api/src/main/java/com/absmartly/sdk/json/ContextData.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
@JsonIgnoreProperties(ignoreUnknown = true)
public class ContextData {
public Experiment[] experiments = new Experiment[0];
public ExperimentHoldout[] holdouts = new ExperimentHoldout[0];

public ContextData() {}

Expand All @@ -19,25 +20,34 @@ public ContextData(Experiment[] experiments) {
this.experiments = experiments;
}

@SuppressFBWarnings(value = "EI_EXPOSE_REP2")
public ContextData(Experiment[] experiments, ExperimentHoldout[] holdouts) {
this.experiments = experiments;
this.holdouts = holdouts;
}

@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;
ContextData that = (ContextData) o;
return Arrays.equals(experiments, that.experiments);
return Arrays.equals(experiments, that.experiments) && Arrays.equals(holdouts, that.holdouts);
}

@Override
public int hashCode() {
return Arrays.hashCode(experiments);
int result = Arrays.hashCode(experiments);
result = 31 * result + Arrays.hashCode(holdouts);
return result;
}

@Override
public String toString() {
return "ContextData{" +
"experiments=" + Arrays.toString(experiments) +
", holdouts=" + Arrays.toString(holdouts) +
'}';
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ public class Experiment {
public boolean audienceStrict;
public String audience;
public CustomFieldValue[] customFieldValues;
public int[] holdoutIds;

public Experiment() {}

Expand Down Expand Up @@ -66,7 +67,9 @@ public boolean equals(Object o) {
return false;
if (audience != null ? !audience.equals(that.audience) : that.audience != null)
return false;
return Arrays.equals(customFieldValues, that.customFieldValues);
if (!Arrays.equals(customFieldValues, that.customFieldValues))
return false;
return Arrays.equals(holdoutIds, that.holdoutIds);
}

@Override
Expand All @@ -87,6 +90,7 @@ public int hashCode() {
result = 31 * result + (audienceStrict ? 1 : 0);
result = 31 * result + (audience != null ? audience.hashCode() : 0);
result = 31 * result + Arrays.hashCode(customFieldValues);
result = 31 * result + Arrays.hashCode(holdoutIds);
return result;
}

Expand All @@ -109,6 +113,7 @@ public String toString() {
", audienceStrict=" + audienceStrict +
", audience='" + audience + '\'' +
", customFieldValues=" + Arrays.toString(customFieldValues) +
", holdoutIds=" + Arrays.toString(holdoutIds) +
'}';
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
package com.absmartly.sdk.json;

import java.util.Arrays;

import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.fasterxml.jackson.annotation.JsonInclude;

import edu.umd.cs.findbugs.annotations.SuppressFBWarnings;

@JsonInclude(JsonInclude.Include.NON_NULL)
@JsonIgnoreProperties(ignoreUnknown = true)
public class ExperimentHoldout {
public int id;
public int seedHi;
public int seedLo;
public double[] split;
public Boolean fullOn;

public ExperimentHoldout() {}

@SuppressFBWarnings(value = "EI_EXPOSE_REP2")
public ExperimentHoldout(int id, int seedHi, int seedLo, double[] split) {
this(id, seedHi, seedLo, split, null);
}

@SuppressFBWarnings(value = "EI_EXPOSE_REP2")
public ExperimentHoldout(int id, int seedHi, int seedLo, double[] split, Boolean fullOn) {
this.id = id;
this.seedHi = seedHi;
this.seedLo = seedLo;
this.split = split;
this.fullOn = fullOn;
}

@Override
public boolean equals(Object o) {
if (this == o)
return true;
if (o == null || getClass() != o.getClass())
return false;

ExperimentHoldout that = (ExperimentHoldout) o;

if (id != that.id)
return false;
if (seedHi != that.seedHi)
return false;
if (seedLo != that.seedLo)
return false;
if (fullOn != null ? !fullOn.equals(that.fullOn) : that.fullOn != null)
return false;
return Arrays.equals(split, that.split);
}

@Override
public int hashCode() {
int result = id;
result = 31 * result + seedHi;
result = 31 * result + seedLo;
result = 31 * result + Arrays.hashCode(split);
result = 31 * result + (fullOn != null ? fullOn.hashCode() : 0);
return result;
}

@Override
public String toString() {
return "ExperimentHoldout{" +
"id=" + id +
", seedHi=" + seedHi +
", seedLo=" + seedLo +
", split=" + Arrays.toString(split) +
", fullOn=" + fullOn +
'}';
}
}
Loading
Loading