Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add PartialEvaluationEngine to support web experiment remote evaluation #8

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ on:
version:
required: true
type: string
description: Release Vesrion (no 'v' prefix)
description: Release Version (no 'v' prefix)
dryRun:
required: true
type: boolean
Expand Down Expand Up @@ -91,6 +91,7 @@ jobs:
SIGNING_KEY: ${{ secrets.SIGNING_KEY }}
run: |
./gradlew evaluation-core:publishKotlinMultiplatformPublicationToSonatypeRepository
./gradlew evaluation-core:publishJvmPublicationToSonatypeRepository

- name: Set Version (evaluation-interop)
if: ${{ github.event.inputs.dryRun == 'false' && (github.event.inputs.releaseModule == 'evaluation-interop' || github.event.inputs.releaseModule == 'all') }}
Expand Down
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.1.1"
version = "2.2.0-alpha.2"

kotlin {

Expand Down
6 changes: 3 additions & 3 deletions evaluation-core/src/commonMain/kotlin/EvaluationEngine.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ interface EvaluationEngine {
): Map<String, EvaluationVariant>
}

class EvaluationEngineImpl(private val log: Logger? = null) : EvaluationEngine {
open class EvaluationEngineImpl(private val log: Logger? = null) : EvaluationEngine {

data class EvaluationTarget(
val context: EvaluationContext,
Expand Down Expand Up @@ -97,7 +97,7 @@ class EvaluationEngineImpl(private val log: Logger? = null) : EvaluationEngine {
return null
}

private fun matchCondition(target: EvaluationTarget, condition: EvaluationCondition): Boolean {
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
Expand All @@ -121,7 +121,7 @@ class EvaluationEngineImpl(private val log: Logger? = null) : EvaluationEngine {
return value.toLong() and 0xffffffffL
}

private fun bucket(target: EvaluationTarget, segment: EvaluationSegment): String? {
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.
Expand Down
94 changes: 94 additions & 0 deletions evaluation-core/src/commonMain/kotlin/PartialEvaluationEngine.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.amplitude.experiment.evaluation

class PartialEvaluationEngine(log: Logger? = null) : EvaluationEngineImpl(log) {
fun partialEvaluate(
context: EvaluationContext,
flags: List<EvaluationFlag>
): List<EvaluationFlag> {
val target = EvaluationTarget(context, mutableMapOf())
val partiallyEvaluatedFlags = mutableListOf<EvaluationFlag>()

for (flag in flags) {
val partiallyEvaluatedSegments = flag.segments.map { segment ->
partialEvaluateSegment(segment, target)
}
val partiallyEvaluatedFlag = EvaluationFlag(
flag.key,
flag.variants,
partiallyEvaluatedSegments,
flag.dependencies,
flag.metadata
)
partiallyEvaluatedFlags.add(partiallyEvaluatedFlag)
}
return partiallyEvaluatedFlags
}

internal fun partialEvaluateSegment(
segment: EvaluationSegment,
target: EvaluationTarget
): EvaluationSegment {
var bucket = segment.bucket
val metadata = segment.metadata
val evaluationConditions = segment.conditions
var variant = segment.variant

/*
* Bucketing is successful when:
* 1. bucket is not null
* 2. bucket selector is not empty
* 3. remote property exists
* If bucketing is successful, simplify the bucket - make bucket null and set default variant to bucketed
* variant else keep the bucket AS IS, this way, on local eval, when conditions match, the correct bucketed variant
* will be returned
*/

if (bucket != null && bucket.selector.isNotEmpty() && target.select(bucket.selector) != null) {
bucket = null
variant = bucket(target, segment)
}

if (evaluationConditions == null) {
return EvaluationSegment(bucket, null, variant, metadata)
}

val orConditions = mutableListOf<List<EvaluationCondition>>()
// if the targeted property exists AND matches the user context, remove it
for (conditions in evaluationConditions) {
var andConditions = mutableListOf<EvaluationCondition>()
for (condition in conditions) {
// if the targeted property exists
if (target.select(condition.selector) != null) {
// if the property does not match the user context, replace the whole AND-condition with an
// ALWAYS-FALSE, else leave it out (this is the same as ALWAYS-TRUE)
if (!matchCondition(target, condition)) {
andConditions = mutableListOf(
EvaluationCondition(
listOf(),
EvaluationOperator.IS_NOT,
setOf("(none)")
)
Comment on lines +66 to +70
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could replace this with an ALWAYS_FALSE variable to be clear about what this does

)
break
}
} else {
// else keep the condition for local evaluation
andConditions.add(condition)
}
}
// if no and-conditions remain, this means all conditions matched
if (andConditions.isEmpty()) {
// set up an EvaluationCondition that always matches
andConditions.add(
EvaluationCondition(
listOf(),
EvaluationOperator.IS,
setOf("(none)")
)
Comment on lines +83 to +87
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

replace with ALWAYS_TRUE variable.

)
}
orConditions.add(andConditions)
}
return EvaluationSegment(bucket, orConditions, variant, metadata)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -884,7 +884,7 @@ class EvaluationIntegrationTest {
}
}

private fun userContext(
internal fun userContext(
userId: String? = null,
deviceId: String? = null,
amplitudeId: String? = null,
Expand All @@ -905,7 +905,7 @@ private fun userContext(
}
}

private fun freeformUserContext(
internal fun freeformUserContext(
user: Map<String, Any?>
): EvaluationContext {
return EvaluationContext().apply {
Expand Down
224 changes: 224 additions & 0 deletions evaluation-core/src/commonTest/kotlin/PartialEvaluationTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,224 @@
package com.amplitude.experiment.evaluation

import com.amplitude.experiment.evaluation.util.FlagApi
import kotlinx.coroutines.runBlocking
import kotlin.test.DefaultAsserter
import kotlin.test.Test

private const val DEPLOYMENT_KEY = "server-NgJxxvg8OGwwBsWVXqyxQbdiflbhvugy"

class PartialEvaluationTest {
private val engine: EvaluationEngine = EvaluationEngineImpl()
private val partialEvaluationEngine: PartialEvaluationEngine = PartialEvaluationEngine()
private val flags: List<EvaluationFlag> = runBlocking {
FlagApi().getFlagConfigs(DEPLOYMENT_KEY)
}

@Test
fun `test partial evaluate segments`() {
// test condition translations
// if only one condition matches, that condition should be removed
val segmentFlag = flags.find { it.key == "test-partial-evaluate-segment" }
var user = freeformUserContext(mapOf("language" to "English"))
var partialEvaluatedSegment = partialEvaluationEngine.partialEvaluateSegment(
segmentFlag?.segments?.get(0) ?: EvaluationSegment(),
EvaluationEngineImpl.EvaluationTarget(user, mutableMapOf())
)
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
1,
partialEvaluatedSegment.conditions?.get(0)?.size
)
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
listOf("context", "user", "platform"),
partialEvaluatedSegment.conditions?.get(0)?.get(0)?.selector
)

// if both conditions match, both conditions should be removed, replaced with an ALWAY-MATCH condition
user = freeformUserContext(mapOf("language" to "English", "platform" to "iOS"))
partialEvaluatedSegment = partialEvaluationEngine.partialEvaluateSegment(
segmentFlag?.segments?.get(0) ?: EvaluationSegment(),
EvaluationEngineImpl.EvaluationTarget(user, mutableMapOf())
)
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
1,
partialEvaluatedSegment.conditions?.get(0)?.size
)
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
EvaluationCondition(
listOf(),
EvaluationOperator.IS,
setOf("(none)")
),
partialEvaluatedSegment.conditions?.get(0)?.get(0)
)

// if both props exist, but either condition DOES NOT match, the condition should be ALWAYS-FALSE
user = freeformUserContext(mapOf("language" to "English", "platform" to "Android"))
partialEvaluatedSegment = partialEvaluationEngine.partialEvaluateSegment(
segmentFlag?.segments?.get(0) ?: EvaluationSegment(),
EvaluationEngineImpl.EvaluationTarget(user, mutableMapOf())
)
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
1,
partialEvaluatedSegment.conditions?.get(0)?.size
)
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
EvaluationCondition(
listOf(),
EvaluationOperator.IS_NOT,
setOf("(none)")
),
partialEvaluatedSegment.conditions?.get(0)?.get(0)
)

// test bucketing translations
// if bucketing segment matches, the bucket should be set to null and the default bucket should be set to the
// bucketed variant
user = freeformUserContext(mapOf("device_id" to "device_id"))
partialEvaluatedSegment = partialEvaluationEngine.partialEvaluateSegment(
segmentFlag?.segments?.get(1) ?: EvaluationSegment(),
EvaluationEngineImpl.EvaluationTarget(user, mutableMapOf())
)
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
"b",
partialEvaluatedSegment.variant
)

// if bucketing unit does not exist remotely, bucket stays unchanged
user = freeformUserContext(mapOf())
partialEvaluatedSegment = partialEvaluationEngine.partialEvaluateSegment(
segmentFlag?.segments?.get(1) ?: EvaluationSegment(),
EvaluationEngineImpl.EvaluationTarget(user, mutableMapOf())
)
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
"off",
partialEvaluatedSegment.variant
)
DefaultAsserter.assertNotNull(
"Unexpected evaluation result",
partialEvaluatedSegment.bucket
)
}

@Test
fun `test partial evaluate conditions`() {
// test remote user props match segment 1
var remoteUser = freeformUserContext(mapOf("language" to "English"))
var partialEvaluatedFlags = partialEvaluationEngine.partialEvaluate(
remoteUser,
flags
)
var localUser = userContext(deviceId = "device_id")
var result = engine.evaluate(localUser, partialEvaluatedFlags)["test-partial-evaluate-condition"]
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
"a",
result?.key
)

// test remote user props match segment 2
remoteUser = freeformUserContext(mapOf("platform" to "iOS"))
partialEvaluatedFlags = partialEvaluationEngine.partialEvaluate(
remoteUser,
flags
)
result = engine.evaluate(localUser, partialEvaluatedFlags)["test-partial-evaluate-condition"]
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
"b",
result?.key
)

// test remote user props match neither segment 1 nor 2
remoteUser = freeformUserContext(mapOf("language" to "A", "platform" to "B"))
partialEvaluatedFlags = partialEvaluationEngine.partialEvaluate(
remoteUser,
flags
)
result = engine.evaluate(localUser, partialEvaluatedFlags)["test-partial-evaluate-condition"]
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
"off",
result?.key
)

// test remote user props does not match segment 1, and only exists (and matches) locally for segment 2
remoteUser = freeformUserContext(mapOf("language" to "A"))
partialEvaluatedFlags = partialEvaluationEngine.partialEvaluate(
remoteUser,
flags
)
localUser = freeformUserContext(mapOf("platform" to "iOS"))
result = engine.evaluate(localUser, partialEvaluatedFlags)["test-partial-evaluate-condition"]
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
"b",
result?.key
)

// test remote user props does not match segment 1, and only exists (and DOES NOT match) locally for segment 2
remoteUser = freeformUserContext(mapOf("language" to "A"))
partialEvaluatedFlags = partialEvaluationEngine.partialEvaluate(
remoteUser,
flags
)
localUser = freeformUserContext(mapOf("platform" to "Android"))
result = engine.evaluate(localUser, partialEvaluatedFlags)["test-partial-evaluate-condition"]
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
"off",
result?.key
)
}

@Test
fun `test partial evaluate bucketing`() {
// bucketing unit (user_id) exists remotely BUT NOT locally
var remoteUser = userContext(userId = "user_id")
var partialEvaluatedFlags = partialEvaluationEngine.partialEvaluate(
remoteUser,
flags
)
var localUser = freeformUserContext(mapOf("platform" to "iOS"))
var result = engine.evaluate(localUser, partialEvaluatedFlags)["test-partial-evaluate-bucketing"]
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
"a",
result?.key
)

// bucketing unit exists neither remotely NOR locally
remoteUser = userContext()
partialEvaluatedFlags = partialEvaluationEngine.partialEvaluate(
remoteUser,
flags
)
result = engine.evaluate(localUser, partialEvaluatedFlags)["test-partial-evaluate-bucketing"]
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
"off",
result?.key
)

// bucketing unit does not exist remotely but does locally
localUser = freeformUserContext(mapOf("user_id" to "id", "platform" to "iOS"))
partialEvaluatedFlags = partialEvaluationEngine.partialEvaluate(
remoteUser,
flags
)
result = engine.evaluate(localUser, partialEvaluatedFlags)["test-partial-evaluate-bucketing"]
DefaultAsserter.assertEquals(
"Unexpected evaluation result",
"a",
result?.key
)
}
}
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.1.1"
version = "2.2.0-alpha.2"

kotlin {

Expand Down
Loading