feat: support relative weighting for fractional evaluation (#1313)

Closes #1282 

This PR adds support for using relative weights instead of percentages
that need to add up to 100.
The behavior for existing flag configs does not change with this PR, so
those will continue to work as they did previously

---------

Signed-off-by: Florian Bacher <florian.bacher@dynatrace.com>
Signed-off-by: Todd Baert <todd.baert@dynatrace.com>
Co-authored-by: Todd Baert <todd.baert@dynatrace.com>
This commit is contained in:
Florian Bacher 2024-06-27 18:50:34 +02:00 committed by GitHub
parent b20266ed5e
commit f82c094f5c
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 219 additions and 62 deletions

View File

@ -16,8 +16,21 @@ type Fractional struct {
}
type fractionalEvaluationDistribution struct {
variant string
percentage int
totalWeight int
weightedVariants []fractionalEvaluationVariant
}
type fractionalEvaluationVariant struct {
variant string
weight int
}
func (v fractionalEvaluationVariant) getPercentage(totalWeight int) float64 {
if totalWeight == 0 {
return 0
}
return 100 * float64(v.weight) / float64(totalWeight)
}
func NewFractional(logger *logger.Logger) *Fractional {
@ -34,7 +47,7 @@ func (fe *Fractional) Evaluate(values, data any) any {
return distributeValue(valueToDistribute, feDistributions)
}
func parseFractionalEvaluationData(values, data any) (string, []fractionalEvaluationDistribution, error) {
func parseFractionalEvaluationData(values, data any) (string, *fractionalEvaluationDistribution, error) {
valuesArray, ok := values.([]any)
if !ok {
return "", nil, errors.New("fractional evaluation data is not an array")
@ -77,9 +90,11 @@ func parseFractionalEvaluationData(values, data any) (string, []fractionalEvalua
return bucketBy, feDistributions, nil
}
func parseFractionalEvaluationDistributions(values []any) ([]fractionalEvaluationDistribution, error) {
sumOfPercentages := 0
var feDistributions []fractionalEvaluationDistribution
func parseFractionalEvaluationDistributions(values []any) (*fractionalEvaluationDistribution, error) {
feDistributions := &fractionalEvaluationDistribution{
totalWeight: 0,
weightedVariants: make([]fractionalEvaluationVariant, len(values)),
}
for i := 0; i < len(values); i++ {
distributionArray, ok := values[i].([]any)
if !ok {
@ -87,8 +102,8 @@ func parseFractionalEvaluationDistributions(values []any) ([]fractionalEvaluatio
"please check your rule in flag definition")
}
if len(distributionArray) != 2 {
return nil, errors.New("distribution element isn't length 2")
if len(distributionArray) == 0 {
return nil, errors.New("distribution element needs at least one element")
}
variant, ok := distributionArray[0].(string)
@ -96,37 +111,36 @@ func parseFractionalEvaluationDistributions(values []any) ([]fractionalEvaluatio
return nil, errors.New("first element of distribution element isn't string")
}
percentage, ok := distributionArray[1].(float64)
if !ok {
return nil, errors.New("second element of distribution element isn't float")
weight := 1.0
if len(distributionArray) >= 2 {
distributionWeight, ok := distributionArray[1].(float64)
if ok {
// default the weight to 1 if not specified explicitly
weight = distributionWeight
}
}
sumOfPercentages += int(percentage)
feDistributions = append(feDistributions, fractionalEvaluationDistribution{
variant: variant,
percentage: int(percentage),
})
}
if sumOfPercentages != 100 {
return nil, fmt.Errorf("percentages must sum to 100, got: %d", sumOfPercentages)
feDistributions.totalWeight += int(weight)
feDistributions.weightedVariants[i] = fractionalEvaluationVariant{
variant: variant,
weight: int(weight),
}
}
return feDistributions, nil
}
// distributeValue calculate hash for given hash key and find the bucket distributions belongs to
func distributeValue(value string, feDistribution []fractionalEvaluationDistribution) string {
func distributeValue(value string, feDistribution *fractionalEvaluationDistribution) string {
hashValue := int32(murmur3.StringSum32(value))
hashRatio := math.Abs(float64(hashValue)) / math.MaxInt32
bucket := int(hashRatio * 100) // in range [0, 100]
bucket := hashRatio * 100 // in range [0, 100]
rangeEnd := 0
for _, dist := range feDistribution {
rangeEnd += dist.percentage
rangeEnd := float64(0)
for _, weightedVariant := range feDistribution.weightedVariants {
rangeEnd += weightedVariant.getPercentage(feDistribution.totalWeight)
if bucket < rangeEnd {
return dist.variant
return weightedVariant.variant
}
}

View File

@ -7,6 +7,7 @@ import (
"github.com/open-feature/flagd/core/pkg/logger"
"github.com/open-feature/flagd/core/pkg/model"
"github.com/open-feature/flagd/core/pkg/store"
"github.com/stretchr/testify/assert"
)
func TestFractionalEvaluation(t *testing.T) {
@ -318,7 +319,7 @@ func TestFractionalEvaluation(t *testing.T) {
expectedValue: "#FF0000",
expectedReason: model.DefaultReason,
},
"fallback to default variant if percentages don't sum to 100": {
"get variant for non-percentage weight values": {
flags: Flags{
Flags: map[string]model.Flag{
"headerColor": {
@ -352,7 +353,41 @@ func TestFractionalEvaluation(t *testing.T) {
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.DefaultReason,
expectedReason: model.TargetingMatchReason,
},
"get variant for non-specified weight values": {
flags: Flags{
Flags: map[string]model.Flag{
"headerColor": {
State: "ENABLED",
DefaultVariant: "red",
Variants: map[string]any{
"red": "#FF0000",
"blue": "#0000FF",
"green": "#00FF00",
"yellow": "#FFFF00",
},
Targeting: []byte(`{
"fractional": [
{"var": "email"},
[
"red"
],
[
"blue"
]
]
}`),
},
},
},
flagKey: "headerColor",
context: map[string]any{
"email": "foo@foo.com",
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
},
"default to targetingKey if no bucket key provided": {
flags: Flags{
@ -579,3 +614,49 @@ func BenchmarkFractionalEvaluation(b *testing.B) {
})
}
}
func Test_fractionalEvaluationVariant_getPercentage(t *testing.T) {
type fields struct {
variant string
weight int
}
type args struct {
totalWeight int
}
tests := []struct {
name string
fields fields
args args
want float64
}{
{
name: "get percentage",
fields: fields{
weight: 10,
},
args: args{
totalWeight: 20,
},
want: 50,
},
{
name: "total weight 0",
fields: fields{
weight: 10,
},
args: args{
totalWeight: 0,
},
want: 0,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
v := fractionalEvaluationVariant{
variant: tt.fields.variant,
weight: tt.fields.weight,
}
assert.Equalf(t, tt.want, v.getPercentage(tt.args.totalWeight), "getPercentage(%v)", tt.args.totalWeight)
})
}
}

View File

@ -17,8 +17,7 @@ OpenFeature allows clients to pass contextual information which can then be used
{ "var": "email" }
]
},
// Split definitions contain an array with a variant and percentage
// Percentages must add up to 100
// Split definitions contain an array with a variant and relative weights
[
// Must match a variant defined in the flag definition
"red",
@ -34,6 +33,31 @@ OpenFeature allows clients to pass contextual information which can then be used
]
```
If not specified, the default weight for a variant is set to `1`, so an alternative to the example above would be the following:
```js
// Factional evaluation property name used in a targeting rule
"fractional": [
// Evaluation context property used to determine the split
// Note using `cat` and `$flagd.flagKey` is the suggested default to seed your hash value and prevent bucketing collisions
{
"cat": [
{ "var": "$flagd.flagKey" },
{ "var": "email" }
]
},
// Split definitions contain an array with a variant and relative weights
[
// Must match a variant defined in the flag definition
"red"
],
[
// Must match a variant defined in the flag definition
"green"
]
]
```
See the [headerColor](https://github.com/open-feature/flagd/blob/main/samples/example_flags.flagd.json#L88-#L133) flag.
The `defaultVariant` is `red`, but it contains a [targeting rule](../flag-definitions.md#targeting-rules), meaning a fractional evaluation occurs for flag evaluation with a `context` object containing `email` and where that `email` value contains `@faas.com`.
@ -44,7 +68,7 @@ The value retrieved by this expression is referred to as the "bucketing value".
The bucketing value expression can be omitted, in which case a concatenation of the `targetingKey` and the `flagKey` will be used.
The `fractional` operation is a custom JsonLogic operation which deterministically selects a variant based on
the defined distribution of each variant (as a percentage).
the defined distribution of each variant (as a relative weight).
This works by hashing ([murmur3](https://github.com/aappleby/smhasher/blob/master/src/MurmurHash3.cpp))
the given data point, converting it into an int in the range [0, 99].
Whichever range this int falls in decides which variant
@ -56,8 +80,11 @@ The value is an array and the first element is a nested JsonLogic rule which res
This rule should typically consist of a seed concatenated with a session variable to use from the evaluation context.
This value should typically be something that remains consistent for the duration of a users session (e.g. email or session ID).
The seed is typically the flagKey so that experiments running across different flags are statistically independent, however, you can also specify another seed to either align or further decouple your allocations across different feature flags or use-cases.
The other elements in the array are nested arrays with the first element representing a variant and the second being the percentage that this option is selected.
There is no limit to the number of elements but the configured percentages must add up to 100.
The other elements in the array are nested arrays with the first element representing a variant and the second being the relative weight for this option.
There is no limit to the number of elements.
> [!NOTE]
> Older versions of the `fractional` operation were percentage based, and required all variants weights to sum to 100.
## Example

View File

@ -1,10 +1,10 @@
# Fractional Operation Specification
This evaluator allows to split the returned variants of a feature flag into different buckets,
where each bucket can be assigned a percentage, representing how many requests will resolve to the corresponding
where each bucket can be assigned a weight, representing how many requests will resolve to the corresponding
variant.
The sum of all weights must be `100`, and the distribution must be performed by using the value of a referenced
The distribution must be performed by using the value of a referenced property
from the evaluation context to hash that value and map it to a value between [0, 100]. It is important to note
that evaluations MUST be sticky, meaning that flag resolution requests containing the same value for the
referenced property in their context MUST always resolve to the same variant. For calculating the hash value of the
@ -15,10 +15,11 @@ regardless of which implementation of the in-process flagd provider is being use
The supplied array must contain at least two items, with the first item being an optional [json logic variable declaration](https://jsonlogic.com/operations.html#var)
specifying the bucketing property to base the distribution of values on. If the bucketing property expression doesn't return a string, a concatenation of the
`flagKey` and `targetingKey` are used: `{"cat": [{"var":"$flagd.flagKey"}, {"var":"targetingKey"}]}`.
The remaining items are `arrays`, each with two values, with the first being `string` item representing the name of the variant, and the
second being a `float` item representing the percentage for that variant. The percentages of all items must add up to
100.0, otherwise unexpected behavior can occur during the evaluation. The `data` object can be an arbitrary
JSON object. Below is an example of a targeting rule containing a `fractional`:
The remaining items are `arrays`, each with at least one value, with the first being `string` item representing the name of the variant, and the
second being an optional `float` item representing the relative weight for that variant.
If no relative weight is specified explicitly, the weight for that variant must be set to `1` be default.
The `data` object can be an arbitrary
JSON object. Below is an example of a targeting rule containing a `fractional`, with relative weights assigned to the variants:
```json
{
@ -59,6 +60,45 @@ JSON object. Below is an example of a targeting rule containing a `fractional`:
}
```
An example for a `fractional` targeting rule with no relative weights being assigned to the variants is listed below.
This will achieve an even distribution of the different variants:
```json
{
"$schema": "https://flagd.dev/schema/v0/flags.json",
"flags": {
"headerColor": {
"variants": {
"red": "#FF0000",
"blue": "#0000FF",
"green": "#00FF00"
},
"defaultVariant": "red",
"state": "ENABLED",
"targeting": {
"fractional": [
{
"cat": [
{ "var": "$flagd.flagKey" },
{ "var": "email" }
]
},
[
"red"
],
[
"blue"
],
[
"green"
]
]
}
}
}
}
```
Please note that the implementation of this evaluator can assume that instead of `{"var": "email"}`, it will receive
the resolved value of that referenced property, as resolving the value will be taken care of by JsonLogic before
applying the evaluator.
@ -72,14 +112,12 @@ B -- Yes --> C{Does expression at index 0 return a string?};
B -- No --> D[return null]
C -- No --> E[bucketingPropertyValue := default to targetingKey];
C -- Yes --> F[bucketingPropertyValue := targetingRule at index 0];
E --> G[Iterate through the remaining elements of the targetingRule array and parse the variants and their percentages];
E --> G[Iterate through the remaining elements of the targetingRule array and parse the variants and their relative weights];
F --> G;
G --> H{Parsing successful?};
H -- No --> D;
H -- Yes --> I{Does percentage of variants add up to 100?};
I -- No --> D;
I -- Yes --> J[hash := murmur3Hash of bucketingPropertyValue divided by Int64.MaxValue]
J --> K[Iterate through the variant and increment the threshold by the percentage of each variant. Return the first variant where the bucket is smaller than the threshold.]
H -- Yes --> J[hash := murmur3Hash of bucketingPropertyValue divided by Int64.MaxValue]
J --> K[Iterate through the variant and increment the threshold by the relative weight of each variant. Return the first variant where the bucket is smaller than the threshold.]
```
As a reference, below is a simplified version of the actual implementation of this evaluator in Go.
@ -88,7 +126,7 @@ As a reference, below is a simplified version of the actual implementation of th
type fractionalEvaluationDistribution struct {
variant string
percentage int
weight int
}
/*
@ -134,7 +172,7 @@ func FractionalEvaluation(values, data interface{}) interface{} {
}
// 3. Parse the fractional values distribution
sumOfPercentages := 0
sumOfWeights := 0
var feDistributions []fractionalEvaluationDistribution
// start at index 1, as the first item of the values array is the target property
@ -145,8 +183,8 @@ func FractionalEvaluation(values, data interface{}) interface{} {
return nil
}
if len(distributionArray) != 2 {
log.Error("distribution element isn't length 2")
if len(distributionArray) == 0 {
log.Error("distribution element needs to have a least one value")
return nil
}
@ -156,26 +194,23 @@ func FractionalEvaluation(values, data interface{}) interface{} {
return nil
}
percentage, ok := distributionArray[1].(float64)
if !ok {
log.Error("second element of distribution element isn't float")
return nil
weight := 1.0
if len(distributionArray) >= 2 {
distributionWeight, ok := distributionArray[1].(float64)
if ok {
// default the weight to 1 if not specified explicitly
weight = distributionWeight
}
}
sumOfPercentages += int(percentage)
sumOfWeights += int(weight)
feDistributions = append(feDistributions, fractionalEvaluationDistribution{
variant: variant,
percentage: int(percentage),
weight: int(weight),
})
}
// check if the sum of percentages adds up to 100, otherwise log an error
if sumOfPercentages != 100 {
log.Error("percentages must sum to 100, got: %d", sumOfPercentages)
return nil
}
// 4. Calculate the hash of the target property and map it to a number between [0, 99]
hashValue := int32(murmur3.StringSum32(value))
hashRatio := math.Abs(float64(hashValue)) / math.MaxInt32
@ -185,7 +220,7 @@ func FractionalEvaluation(values, data interface{}) interface{} {
// return the first variant where the bucket is smaller than the threshold.
rangeEnd := 0
for _, dist := range feDistribution {
rangeEnd += dist.percentage
rangeEnd += (dist.weight / sumOfWeights) * 100
if bucket < rangeEnd {
// return the matching variant
return dist.variant