diff --git a/core/pkg/evaluator/fractional.go b/core/pkg/evaluator/fractional.go index 085b9d0fc..71e363b3e 100644 --- a/core/pkg/evaluator/fractional.go +++ b/core/pkg/evaluator/fractional.go @@ -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 } } diff --git a/core/pkg/evaluator/fractional_test.go b/core/pkg/evaluator/fractional_test.go index 2264c7f29..fc431cbd5 100644 --- a/core/pkg/evaluator/fractional_test.go +++ b/core/pkg/evaluator/fractional_test.go @@ -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) + }) + } +} diff --git a/docs/reference/custom-operations/fractional-operation.md b/docs/reference/custom-operations/fractional-operation.md index 51d8655a3..c981c926b 100644 --- a/docs/reference/custom-operations/fractional-operation.md +++ b/docs/reference/custom-operations/fractional-operation.md @@ -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 diff --git a/docs/reference/specifications/custom-operations/fractional-operation-spec.md b/docs/reference/specifications/custom-operations/fractional-operation-spec.md index 35c65d448..27a205bc4 100644 --- a/docs/reference/specifications/custom-operations/fractional-operation-spec.md +++ b/docs/reference/specifications/custom-operations/fractional-operation-spec.md @@ -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