Skip to content

Commit

Permalink
feat: support relative weighting for fractional evaluation (#1313)
Browse files Browse the repository at this point in the history
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 <[email protected]>
Signed-off-by: Todd Baert <[email protected]>
Co-authored-by: Todd Baert <[email protected]>
  • Loading branch information
bacherfl and toddbaert committed Jun 27, 2024
1 parent b20266e commit f82c094
Show file tree
Hide file tree
Showing 4 changed files with 219 additions and 62 deletions.
68 changes: 41 additions & 27 deletions core/pkg/evaluator/fractional.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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")
Expand Down Expand Up @@ -77,56 +90,57 @@ 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 {
return nil, errors.New("distribution elements aren't of type []any. " +
"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)
if !ok {
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
}
}

Expand Down
85 changes: 83 additions & 2 deletions core/pkg/evaluator/fractional_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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": {
Expand Down Expand Up @@ -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": "[email protected]",
},
expectedVariant: "red",
expectedValue: "#FF0000",
expectedReason: model.TargetingMatchReason,
},
"default to targetingKey if no bucket key provided": {
flags: Flags{
Expand Down Expand Up @@ -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)
})
}
}
37 changes: 32 additions & 5 deletions docs/reference/custom-operations/fractional-operation.md
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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`.

Expand All @@ -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
Expand All @@ -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

Expand Down
Loading

0 comments on commit f82c094

Please sign in to comment.