-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
create PartialEvaluationEngine class and tests
- Loading branch information
Showing
4 changed files
with
315 additions
and
13 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
94 changes: 94 additions & 0 deletions
94
evaluation-core/src/commonMain/kotlin/PartialEvaluationEngine.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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) | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
216 changes: 216 additions & 0 deletions
216
evaluation-core/src/commonTest/kotlin/PartialEvaluationTest.kt
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | ||
) | ||
} | ||
} |