commonMain.EvaluationEngine.kt Maven / Gradle / Ivy
package com.amplitude.experiment.evaluation
import kotlinx.serialization.SerializationException
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.JsonArray
interface EvaluationEngine {
fun evaluate(
context: EvaluationContext,
flags: List
): Map
}
open class EvaluationEngineImpl(private val log: Logger? = null) : EvaluationEngine {
data class EvaluationTarget(
val context: EvaluationContext,
val result: MutableMap
) : Selectable {
override fun select(selector: String): Any? {
return when (selector) {
"context" -> context
"result" -> result
else -> null
}
}
}
override fun evaluate(
context: EvaluationContext,
flags: List
): Map {
log?.debug { "Evaluating flags ${flags.map { it.key }} with context $context." }
val results: MutableMap = mutableMapOf()
val target = EvaluationTarget(context, results)
for (flag in flags) {
// Evaluate flag and update results.
val variant = evaluateFlag(target, flag)
if (variant != null) {
results[flag.key] = variant
} else {
log?.debug { "Flag ${flag.key} evaluation returned a null result." }
}
}
log?.debug { "Evaluation completed. $results" }
return results
}
private fun evaluateFlag(target: EvaluationTarget, flag: EvaluationFlag): EvaluationVariant? {
log?.verbose { "Evaluating flag $flag with target $target." }
var result: EvaluationVariant? = null
for (segment in flag.segments) {
result = evaluateSegment(target, flag, segment)
if (result != null) {
// Merge all metadata into the result
val metadata = mergeMetadata(flag.metadata, segment.metadata, result.metadata)
result = EvaluationVariant(result.key, result.value, result.payload, metadata)
log?.verbose { "Flag evaluation returned result $result on segment $segment." }
break
}
}
return result
}
private fun evaluateSegment(
target: EvaluationTarget,
flag: EvaluationFlag,
segment: EvaluationSegment
): EvaluationVariant? {
log?.verbose { "Evaluating segment $segment with target $target." }
if (segment.conditions == null) {
log?.verbose { "Segment conditions are null, bucketing target." }
// Null conditions always match
val variantKey = bucket(target, segment)
return flag.variants[variantKey]
}
// Outer list logic is "or" (||)
for (conditions in segment.conditions) {
var match = true
// Inner list logic is "and" (&&)
for (condition in conditions) {
match = matchCondition(target, condition)
if (!match) {
log?.verbose { "Segment condition $condition did not match target." }
break
} else {
log?.verbose { "Segment condition $condition matched target." }
}
}
// On match bucket the user.
if (match) {
log?.verbose { "Segment conditions matched, bucketing target." }
val variantKey = bucket(target, segment)
return flag.variants[variantKey]
}
}
return null
}
internal fun matchCondition(target: EvaluationTarget, condition: EvaluationCondition): Boolean {
val propValue = target.select(condition.selector)
// We need special matching for null properties and set type prop values
// and operators. All other values are matched as strings, since the
// filter values are always strings.
if (propValue == null) {
return matchNull(condition.op, condition.values)
} else if (isSetOperator(condition.op)) {
val propValueStringList = coerceStringList(propValue) ?: return false
return matchSet(propValueStringList, condition.op, condition.values)
} else {
val propValueString = coerceString(propValue) ?: return false
return matchString(propValueString, condition.op, condition.values)
}
}
private fun getHash(key: String): Long {
// hash32x86 returns a number that can't fit in a signed 32-bit java integer.
// Source: https://stackoverflow.com/a/24090718/2322146
val data = key.encodeToByteArray()
val value = Murmur3.hash32x86(data, data.size, 0)
return value.toLong() and 0xffffffffL
}
internal fun bucket(target: EvaluationTarget, segment: EvaluationSegment): String? {
log?.verbose { "Bucketing segment $segment with target $target" }
if (segment.bucket == null) {
// A null bucket means the segment is fully rolled out. Select the default variant.
log?.verbose { "Segment bucket is null, returning default variant ${segment.variant}." }
return segment.variant
}
// Select the bucketing value.
val bucketingValue = coerceString(target.select(segment.bucket.selector))
log?.verbose { "Selected bucketing value $bucketingValue from target." }
if (bucketingValue.isNullOrEmpty()) {
// A null or empty bucketing value cannot be bucketed. Select the default variant.
log?.verbose { "Selected bucketing value is null or empty." }
return segment.variant
}
// Salt and hash the value, and compute the allocation and distribution values.
val keyToHash = "${segment.bucket.salt}/$bucketingValue"
val hash = getHash(keyToHash)
// Iterate over allocations. If the value falls within the range, check the distribution.
for (allocation in segment.bucket.allocations) {
val allocationValue = hash % 100
val allocationStart = allocation.range[0]
val allocationEnd = allocation.range[1]
if (allocationValue in allocationStart until allocationEnd) {
for (distribution in allocation.distributions) {
val distributionValue = hash.floorDiv(100)
val distributionStart = distribution.range[0]
val distributionEnd = distribution.range[1]
if (distributionValue in distributionStart until distributionEnd) {
log?.verbose { "Bucketing hit allocation and distribution, returning variant ${distribution.variant}." }
return distribution.variant
}
}
}
}
// No allocation and distribution match. Select the default variant.
return segment.variant
}
private fun mergeMetadata(vararg metadata: Map?): Map? {
val mergedMetadata = mutableMapOf()
for (metadataElement in metadata) {
if (metadataElement != null) {
mergedMetadata.putAll(metadataElement)
}
}
return if (mergedMetadata.isEmpty()) {
null
} else {
mergedMetadata
}
}
private fun matchNull(op: String, filterValues: Set): Boolean {
val containsNone = containsNone(filterValues)
return when (op) {
EvaluationOperator.IS, EvaluationOperator.CONTAINS, EvaluationOperator.LESS_THAN,
EvaluationOperator.LESS_THAN_EQUALS, EvaluationOperator.GREATER_THAN,
EvaluationOperator.GREATER_THAN_EQUALS, EvaluationOperator.VERSION_LESS_THAN,
EvaluationOperator.VERSION_LESS_THAN_EQUALS, EvaluationOperator.VERSION_GREATER_THAN,
EvaluationOperator.VERSION_GREATER_THAN_EQUALS, EvaluationOperator.SET_IS,
EvaluationOperator.SET_CONTAINS, EvaluationOperator.SET_CONTAINS_ANY -> containsNone
EvaluationOperator.IS_NOT, EvaluationOperator.DOES_NOT_CONTAIN,
EvaluationOperator.SET_DOES_NOT_CONTAIN, EvaluationOperator.SET_DOES_NOT_CONTAIN_ANY -> !containsNone
EvaluationOperator.REGEX_MATCH -> false
EvaluationOperator.REGEX_DOES_NOT_MATCH, EvaluationOperator.SET_IS_NOT -> true
else -> false
}
}
private fun matchSet(propValues: Set, op: String, filterValues: Set): Boolean {
return when (op) {
EvaluationOperator.SET_IS -> propValues == filterValues
EvaluationOperator.SET_IS_NOT -> propValues != filterValues
EvaluationOperator.SET_CONTAINS -> matchesSetContainsAll(propValues, filterValues)
EvaluationOperator.SET_DOES_NOT_CONTAIN -> !matchesSetContainsAll(propValues, filterValues)
EvaluationOperator.SET_CONTAINS_ANY -> matchesSetContainsAny(propValues, filterValues)
EvaluationOperator.SET_DOES_NOT_CONTAIN_ANY -> !matchesSetContainsAny(propValues, filterValues)
else -> false
}
}
private fun matchString(propValue: String, op: String, filterValues: Set): Boolean {
return when (op) {
EvaluationOperator.IS -> matchesIs(propValue, filterValues)
EvaluationOperator.IS_NOT -> !matchesIs(propValue, filterValues)
EvaluationOperator.CONTAINS -> matchesContains(propValue, filterValues)
EvaluationOperator.DOES_NOT_CONTAIN -> !matchesContains(propValue, filterValues)
EvaluationOperator.LESS_THAN, EvaluationOperator.LESS_THAN_EQUALS,
EvaluationOperator.GREATER_THAN, EvaluationOperator.GREATER_THAN_EQUALS ->
matchesComparable(propValue, op, filterValues) { value -> parseDouble(value) }
EvaluationOperator.VERSION_LESS_THAN, EvaluationOperator.VERSION_LESS_THAN_EQUALS,
EvaluationOperator.VERSION_GREATER_THAN, EvaluationOperator.VERSION_GREATER_THAN_EQUALS ->
matchesComparable(propValue, op, filterValues) { value -> SemanticVersion.parse(value) }
EvaluationOperator.REGEX_MATCH -> matchesRegex(propValue, filterValues)
EvaluationOperator.REGEX_DOES_NOT_MATCH -> !matchesRegex(propValue, filterValues)
else -> false
}
}
private fun matchesIs(propValue: String, filterValues: Set): Boolean {
if (isBoolean(propValue) && containsBooleans(filterValues)) {
return filterValues.any { propValue.equals(it, ignoreCase = true) }
}
return filterValues.contains(propValue)
}
private fun matchesContains(propValue: String, filterValues: Set): Boolean {
for (filterValue in filterValues) {
if (propValue.lowercase().contains(filterValue.lowercase())) {
return true
}
}
return false
}
private fun matchesSetContainsAll(propValues: Set, filterValues: Set): Boolean {
if (propValues.size < filterValues.size) {
return false
}
for (filterValue in filterValues) {
if (!matchesIs(filterValue, propValues)) {
return false
}
}
return true
}
private fun matchesSetContainsAny(propValues: Set, filterValues: Set): Boolean {
for (filterValue in filterValues) {
if (matchesIs(filterValue, propValues)) {
return true
}
}
return false
}
private fun > matchesComparable(
propValue: String,
op: String,
filterValues: Set,
transformer: (String) -> T?
): Boolean {
val propValueTransformed: T? = transformer.invoke(propValue)
val filterValuesTransformed: Set = filterValues.mapNotNull(transformer).toSet()
return if (propValueTransformed == null || filterValuesTransformed.isEmpty()) {
// If the prop value or none of the filter values transform, fall
// back on string comparison.
filterValues.any { filterValue ->
matchesComparable(propValue, op, filterValue)
}
} else {
// Match only transformed filter values.
filterValuesTransformed.any { filterValueTransformed ->
matchesComparable(propValueTransformed, op, filterValueTransformed)
}
}
}
private fun matchesComparable(propValue: Comparable, op: String, filterValue: T): Boolean {
val compareTo = propValue.compareTo(filterValue)
return when (op) {
EvaluationOperator.LESS_THAN, EvaluationOperator.VERSION_LESS_THAN -> compareTo < 0
EvaluationOperator.LESS_THAN_EQUALS, EvaluationOperator.VERSION_LESS_THAN_EQUALS -> compareTo <= 0
EvaluationOperator.GREATER_THAN, EvaluationOperator.VERSION_GREATER_THAN -> compareTo > 0
EvaluationOperator.GREATER_THAN_EQUALS, EvaluationOperator.VERSION_GREATER_THAN_EQUALS -> compareTo >= 0
else -> throw IllegalArgumentException("Unexpected comparison operator $op")
}
}
private fun matchesRegex(propValue: String, filterValues: Set): Boolean {
return filterValues.any { filterValue -> Regex(filterValue).matches(propValue) }
}
private fun containsNone(filterValues: Set): Boolean {
return filterValues.contains("(none)")
}
private fun isBoolean(value: String): Boolean {
return value.equals("true", ignoreCase = true) ||
value.equals("false", ignoreCase = true)
}
private fun containsBooleans(filterValues: Set): Boolean {
return filterValues.any { filterValue ->
isBoolean(filterValue)
}
}
private fun parseDouble(value: String): Double? {
return try {
value.toDouble()
} catch (e: NumberFormatException) {
null
}
}
private fun coerceString(value: Any?): String? {
return when (value) {
null -> null
is Map<*, *> -> json.encodeToString(value.toJsonObject())
is Collection<*> -> json.encodeToString(value.toJsonArray())
else -> value.toString()
}
}
private fun coerceStringList(value: Any): Set? {
// Convert collections to a list of strings
if (value is Collection<*>) {
return value.mapNotNull { coerceString(it) }.toSet()
}
// Parse a string as json array and convert to list of strings, or
// return null if the string could not be parsed as a json array.
val stringValue = value.toString()
val jsonArray = try {
json.decodeFromString(stringValue)
} catch (e: SerializationException) {
return null
}
return jsonArray.toList().mapNotNull { coerceString(it) }.toSet()
}
private fun isSetOperator(op: String): Boolean {
return when (op) {
EvaluationOperator.SET_IS,
EvaluationOperator.SET_IS_NOT,
EvaluationOperator.SET_CONTAINS,
EvaluationOperator.SET_DOES_NOT_CONTAIN,
EvaluationOperator.SET_CONTAINS_ANY,
EvaluationOperator.SET_DOES_NOT_CONTAIN_ANY -> true
else -> false
}
}
}
© 2015 - 2025 Weber Informatics LLC | Privacy Policy