Skip to content

Commit

Permalink
WIP add model and clean up
Browse files Browse the repository at this point in the history
  • Loading branch information
bgiori committed Feb 17, 2024
1 parent bbcfed5 commit ecdc406
Show file tree
Hide file tree
Showing 10 changed files with 262 additions and 17 deletions.
187 changes: 187 additions & 0 deletions docs/model.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
# Evaluation Model

* Version: `2.0.0`
* Created: 2024-02-02
* Last Modified: -
* Author: Brian Giori (@bgiori)

This document defines the evaluation model.

The model is defined in **Kotlin** syntax, and each data class is serialized as
JSON using `camelCase` as defined by the class variables.

## Flag

A flag defines targeting, bucketing, and variants for a feature.

```kotlin
data class EvaluationFlag(
// The flag key. Must be unique within a project.
val key: String,

// The flag's variants. The result of a flag evaluation is zero or one
// variant.
val variants: Map<String, EvaluationVariant>,

// The targeting segments. targets and buckets users into a variant.
val segments: List<EvaluationSegment>,

// The flag's dependencies, used to order the flags prior to evaluation.
val dependencies: Set<String>? = null,

// An object of metadata for this flag. Contains information useful
// outside evaluation. The bucketing segment's metadata is merged with
// the flag metadata and returned within the evaluation result.
val metadata: Map<String, Any?>? = null
)
```

## Variant

A variant is the result of a flag's evaluation.

```kotlin
data class EvaluationVariant(
// The key must be unique for a flag's variant. I.e. no two variants on one
// flag can have the same key.
val key: String,
// The variant value is used primarily in the application build feature
// logic.
val value: Any? = null,
// The payload may contain additional data for use in the application.
val payload: Any? = null,
// Metadata is aggregated from the flag, segment and variant upon assignment
// and may be used for tracking and debugging purposes, among others.
val metadata: Map<String, Any?>? = null,
)
```

## Segment

A segment targets and buckets users into a variant.

The `conditions` define if the user should be bucketed. If the user should be bucketed, the `bucket` determines which variant the user is assigned. If the conditions or bucket is `null` or the bucket does not assign a variant, then the default `variant` is assigned. If the user is not bucketed, and the `variant` is `null` then the user falls through to the next segment.

```kotlin
data class EvaluationSegment(
// How to bucket the user given a matching condition. If the bucket is null,
// assign the default variant.
val bucket: EvaluationBucket? = null,

// The targeting conditions. On match, bucket the user. The outer list
// is operated with "OR" and the inner list is operated with "AND". If the
// conditions are null, assign the default variant.
val conditions: List<List<EvaluationCondition>>? = null,

// The default variant if the conditions match but either no bucket is set,
// or the bucket does not produce a variant.
val variant: String? = null,

// An object of metadata for this segment. For example, contains the
// segment name and may contain the experiment key associated with this
// segment. The bucketing segment's metadata is passed back in the
// evaluation result after being merged with the along with the vairant and
// flag metadata.
val metadata: Map<String, Any?>? = null
)
```

## Condition

A condition represents a function which returns a boolean value.

The `selector` is used to select a value from the target which is compared to the `values`. The specific behavior of the function depends on the `op`.

```kotlin
data class EvaluationCondition(
// How to select the property from the evaluation state. Each entry in the
// selector will access a key from the target. The resulting value is used
// in the operator function.
val selector: List<String>,

// The operator. Defines the function to use with the selection and values.
val op: String,

// The values to compare to.
val values: Set<String>
)
```

## Bucket

The bucket defines which variant, if any, the user should be assigned.

The `allocations` determine which variant, if any, the user is assigned to. If assigned, the `selector` is used to access the value from the target. The selected value from the target is appended to the `salt` before being hashed.

```kotlin
data class EvaluationBucket(
// How to select the property value from the target.
val selector: List<String>,

// A random string used to salt the bucketing value prior to hashing.
val salt: String,

// Determines which variant, if any, should be returned based on the
// result of the hash functions applied on these allocations.
val allocations: List<EvaluationAllocation>,
)
```

## Allocation

An allocation defines a `range` the `distribution` of variants within that range.

```kotlin
data class EvaluationAllocation(
// The distribution range [0, 100). That is the possibles values are 0-99.
// E.g. [0, 44] is 50% allocation
val range: List<Int>,

// The distribution of variants if allocated.
val distributions: List<EvaluationDistribution>,
)
```

## Distribution

A distribution defines a `range`, and the `variant` to assign if the range matches.

```kotlin
data class EvaluationDistribution(
// The key of the variant to deliver if this range matches.
val variant: String,

// The distribution range [start, end), where the max value is 42949672.
// E.g. [0, 42949673] = [0%, 100%]
val range: List<Int>,
)
```

## Operator

An operation is represented as a `String` in a condition.

```kotlin
object EvaluationOperator {
const val IS = "is"
const val IS_NOT = "is not"
const val CONTAINS = "contains"
const val DOES_NOT_CONTAIN = "does not contain"
const val LESS_THAN = "less"
const val LESS_THAN_EQUALS = "less or equal"
const val GREATER_THAN = "greater"
const val GREATER_THAN_EQUALS = "greater or equal"
const val VERSION_LESS_THAN = "version less"
const val VERSION_LESS_THAN_EQUALS = "version less or equal"
const val VERSION_GREATER_THAN = "version greater"
const val VERSION_GREATER_THAN_EQUALS = "version greater or equal"
const val SET_IS = "set is"
const val SET_IS_NOT = "set is not"
const val SET_CONTAINS = "set contains"
const val SET_DOES_NOT_CONTAIN = "set does not contain"
const val SET_CONTAINS_ANY = "set contains any"
const val SET_DOES_NOT_CONTAIN_ANY = "set does not contain any"
const val REGEX_MATCH = "regex match"
const val REGEX_DOES_NOT_MATCH = "regex does not match"
}
```
2 changes: 1 addition & 1 deletion evaluation-core/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ plugins {
id("org.jlleitschuh.gradle.ktlint") version Versions.kotlinLint
}

version = "2.0.0-beta.2"
version = "2.0.0"

kotlin {

Expand Down
4 changes: 4 additions & 0 deletions evaluation-core/src/commonMain/kotlin/EvaluationAllocation.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import kotlinx.serialization.Serializable

@Serializable
data class EvaluationAllocation(
// The distribution range [0, 100). That is the possibles values are 0-99.
// E.g. [0, 44] is 50% allocation
val range: List<Int>,

// The distribution of variants if allocated.
val distributions: List<EvaluationDistribution>,
)
29 changes: 28 additions & 1 deletion evaluation-core/src/commonMain/kotlin/EvaluationContext.kt
Original file line number Diff line number Diff line change
@@ -1,9 +1,36 @@
@file:UseSerializers(AnySerializer::class)

package com.amplitude.experiment.evaluation

import kotlinx.serialization.KSerializer
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers
import kotlinx.serialization.descriptors.SerialDescriptor
import kotlinx.serialization.encoding.Decoder
import kotlinx.serialization.encoding.Encoder
import kotlinx.serialization.json.JsonObject

@Serializable
@Serializable(EvaluationContextSerializer::class)
class EvaluationContext : MutableMap<String, Any?> by LinkedHashMap(), Selectable {

override fun select(selector: String): Any? = this[selector]
}

internal object EvaluationContextSerializer : KSerializer<EvaluationContext> {
private val delegate = JsonObject.serializer()
override val descriptor: SerialDescriptor
get() = SerialDescriptor("EvaluationContext", delegate.descriptor)

override fun serialize(encoder: Encoder, value: EvaluationContext) {
val jsonObject = value.toJsonObject()
encoder.encodeSerializableValue(delegate, jsonObject)
}

override fun deserialize(decoder: Decoder): EvaluationContext {
val jsonElement = decoder.decodeSerializableValue(delegate)
val map = jsonElement.toMap()
return EvaluationContext().apply {
putAll(map)
}
}
}
2 changes: 1 addition & 1 deletion evaluation-core/src/commonMain/kotlin/EvaluationEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -131,7 +131,7 @@ class EvaluationEngineImpl(private val log: Logger? = DefaultLogger()) : Evaluat
// Select the bucketing value.
val bucketingValue = coerceString(target.select(segment.bucket.selector))
log?.verbose { "Selected bucketing value $bucketingValue from target." }
if (bucketingValue == null || bucketingValue.isEmpty()) {
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
Expand Down
9 changes: 6 additions & 3 deletions evaluation-core/src/commonMain/kotlin/EvaluationFlag.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,16 +5,19 @@ package com.amplitude.experiment.evaluation
import kotlinx.serialization.Serializable
import kotlinx.serialization.UseSerializers

/**
*
*/
@Serializable
data class EvaluationFlag(
// The flag key. Must be unique for deployment.
// The flag key. Must be unique within a project.
val key: String,

// The flag's variants. The result of a flag evaluation is exactly one
// The flag's variants. The result of a flag evaluation is zero or one
// variant.
val variants: Map<String, EvaluationVariant>,

// The targeting segments.
// The targeting segments. targets and buckets users into a variant.
val segments: List<EvaluationSegment>,

// The flag's dependencies, used to order the flags prior to evaluation.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,16 +18,13 @@ import kotlinx.serialization.json.longOrNull
import kotlin.jvm.JvmField
import kotlin.jvm.JvmSynthetic

@JvmSynthetic
@JvmField
internal val json = Json {
ignoreUnknownKeys = true
isLenient = true
coerceInputValues = true
explicitNulls = false
}

@JvmSynthetic
internal fun Any?.toJsonElement(): JsonElement = when (this) {
null -> JsonNull
is Map<*, *> -> toJsonObject()
Expand All @@ -38,17 +35,14 @@ internal fun Any?.toJsonElement(): JsonElement = when (this) {
else -> JsonPrimitive(toString())
}

@JvmSynthetic
internal fun Collection<*>.toJsonArray(): JsonArray = JsonArray(map { it.toJsonElement() })

@JvmSynthetic
internal fun Map<*, *>.toJsonObject(): JsonObject = JsonObject(
mapNotNull {
(it.key as? String ?: return@mapNotNull null) to it.value.toJsonElement()
}.toMap(),
)

@JvmSynthetic
internal fun JsonElement.toAny(): Any? {
return when (this) {
is JsonPrimitive -> toAny()
Expand All @@ -57,7 +51,6 @@ internal fun JsonElement.toAny(): Any? {
}
}

@JvmSynthetic
internal fun JsonPrimitive.toAny(): Any? {
return if (isString) {
contentOrNull
Expand All @@ -66,10 +59,8 @@ internal fun JsonPrimitive.toAny(): Any? {
}
}

@JvmSynthetic
internal fun JsonArray.toList(): List<Any?> = map { it.toAny() }

@JvmSynthetic
internal fun JsonObject.toMap(): Map<String, Any?> = mapValues { it.value.toAny() }

internal object AnySerializer : KSerializer<Any?> {
Expand Down
34 changes: 34 additions & 0 deletions evaluation-core/src/commonTest/kotlin/EvaluationIntegrationTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ import com.amplitude.experiment.evaluation.util.FlagApi
import kotlinx.coroutines.runBlocking
import kotlin.test.DefaultAsserter
import kotlin.test.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
import kotlin.time.measureTime

private const val DEPLOYMENT_KEY = "server-NgJxxvg8OGwwBsWVXqyxQbdiflbhvugy"

Expand Down Expand Up @@ -379,6 +382,23 @@ class EvaluationIntegrationTest {
)
}

@Test
fun test() {
var totalDuration: Duration = 0.milliseconds
val extendedFlags = mutableListOf<EvaluationFlag>()
extendedFlags.addAll(flags)
extendedFlags.addAll(flags)
extendedFlags.addAll(flags)
extendedFlags.addAll(flags)
repeat(100000) { i ->
val user = userContext(deviceId = "${i + 1}")
totalDuration += measureTime {
engine.evaluate(user, extendedFlags)
}
}
println(totalDuration / 100000.0)
}

@Test
fun `test 50 percent allocation`() {
var on = 0
Expand Down Expand Up @@ -858,6 +878,20 @@ class EvaluationIntegrationTest {
result?.key
)
}

@Test
fun `test version compare falls back on string comparison`() {
val user = freeformUserContext(mapOf(
"version" to "1.10."
))

val result = engine.evaluate(user, flags.filter { it.key == "test-version-less" })["test-version-less"]
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
"on",
result?.key
)
}
}

private fun userContext(
Expand Down
2 changes: 1 addition & 1 deletion evaluation-interop/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ plugins {
id("org.jlleitschuh.gradle.ktlint") version Versions.kotlinLint
}

version = "2.0.0-beta.2"
version = "2.0.0"

kotlin {

Expand Down
Loading

0 comments on commit ecdc406

Please sign in to comment.