-
Notifications
You must be signed in to change notification settings - Fork 1
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
tyiuhc
wants to merge
12
commits into
main
Choose a base branch
from
web-remote
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
12 commits
Select commit
Hold shift + click to select a range
dd99016
name package
tyiuhc a121e00
merge
tyiuhc 8a49d28
change release tag
tyiuhc b233d35
release: evaluation-interop 2.2.0-alpha.1
amplitude-sdk-bot cefd985
update release.yml
tyiuhc 2a21eb6
update release.yml
tyiuhc e89bd58
create PartialEvaluationEngine class and tests
tyiuhc 8e71e98
fix lint
tyiuhc 33d9780
fix release.yml
tyiuhc 4299855
fix test
tyiuhc 68e6f85
update test source
tyiuhc e33a97a
update build.gradle.kts
tyiuhc File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
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
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(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)") | ||
) | ||
Comment on lines
+83
to
+87
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. replace with |
||
) | ||
} | ||
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
224 changes: 224 additions & 0 deletions
224
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,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 | ||
) | ||
} | ||
} |
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
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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