Skip to content

Commit

Permalink
create PartialEvaluationEngine class and tests
Browse files Browse the repository at this point in the history
  • Loading branch information
tyiuhc committed Oct 29, 2024
1 parent 2a21eb6 commit e89bd58
Show file tree
Hide file tree
Showing 4 changed files with 315 additions and 13 deletions.
14 changes: 3 additions & 11 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 @@ -46,14 +46,6 @@ class EvaluationEngineImpl(private val log: Logger? = null) : EvaluationEngine {
return results
}

fun matchConditionWrapper(target: EvaluationTarget, condition: EvaluationCondition): Boolean {
return matchCondition(target, condition)
}

fun bucketWrapper(target: EvaluationTarget, segment: EvaluationSegment): String? {
return bucket(target, segment)
}

private fun evaluateFlag(target: EvaluationTarget, flag: EvaluationFlag): EvaluationVariant? {
log?.verbose { "Evaluating flag $flag with target $target." }
var result: EvaluationVariant? = null
Expand Down Expand Up @@ -105,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 @@ -129,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(private val 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)")
)
)
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)")
)
)
}
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
216 changes: 216 additions & 0 deletions evaluation-core/src/commonTest/kotlin/PartialEvaluationTest.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,216 @@
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-BVdl6mbGjMSkY5lIJenbFaGim73JkHbU"

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",
emptyList<String>(),
partialEvaluatedSegment.conditions?.get(0)?.get(0)?.selector
)

// 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",
emptyList<String>(),
partialEvaluatedSegment.conditions?.get(0)?.get(0)?.selector
)

// 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 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
)
}
}

0 comments on commit e89bd58

Please sign in to comment.