Skip to content

Commit f82c094

Browse files
bacherfltoddbaert
andauthored
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 <[email protected]> Signed-off-by: Todd Baert <[email protected]> Co-authored-by: Todd Baert <[email protected]>
1 parent b20266e commit f82c094

File tree

4 files changed

+219
-62
lines changed

4 files changed

+219
-62
lines changed

core/pkg/evaluator/fractional.go

Lines changed: 41 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -16,8 +16,21 @@ type Fractional struct {
1616
}
1717

1818
type fractionalEvaluationDistribution struct {
19-
variant string
20-
percentage int
19+
totalWeight int
20+
weightedVariants []fractionalEvaluationVariant
21+
}
22+
23+
type fractionalEvaluationVariant struct {
24+
variant string
25+
weight int
26+
}
27+
28+
func (v fractionalEvaluationVariant) getPercentage(totalWeight int) float64 {
29+
if totalWeight == 0 {
30+
return 0
31+
}
32+
33+
return 100 * float64(v.weight) / float64(totalWeight)
2134
}
2235

2336
func NewFractional(logger *logger.Logger) *Fractional {
@@ -34,7 +47,7 @@ func (fe *Fractional) Evaluate(values, data any) any {
3447
return distributeValue(valueToDistribute, feDistributions)
3548
}
3649

37-
func parseFractionalEvaluationData(values, data any) (string, []fractionalEvaluationDistribution, error) {
50+
func parseFractionalEvaluationData(values, data any) (string, *fractionalEvaluationDistribution, error) {
3851
valuesArray, ok := values.([]any)
3952
if !ok {
4053
return "", nil, errors.New("fractional evaluation data is not an array")
@@ -77,56 +90,57 @@ func parseFractionalEvaluationData(values, data any) (string, []fractionalEvalua
7790
return bucketBy, feDistributions, nil
7891
}
7992

80-
func parseFractionalEvaluationDistributions(values []any) ([]fractionalEvaluationDistribution, error) {
81-
sumOfPercentages := 0
82-
var feDistributions []fractionalEvaluationDistribution
93+
func parseFractionalEvaluationDistributions(values []any) (*fractionalEvaluationDistribution, error) {
94+
feDistributions := &fractionalEvaluationDistribution{
95+
totalWeight: 0,
96+
weightedVariants: make([]fractionalEvaluationVariant, len(values)),
97+
}
8398
for i := 0; i < len(values); i++ {
8499
distributionArray, ok := values[i].([]any)
85100
if !ok {
86101
return nil, errors.New("distribution elements aren't of type []any. " +
87102
"please check your rule in flag definition")
88103
}
89104

90-
if len(distributionArray) != 2 {
91-
return nil, errors.New("distribution element isn't length 2")
105+
if len(distributionArray) == 0 {
106+
return nil, errors.New("distribution element needs at least one element")
92107
}
93108

94109
variant, ok := distributionArray[0].(string)
95110
if !ok {
96111
return nil, errors.New("first element of distribution element isn't string")
97112
}
98113

99-
percentage, ok := distributionArray[1].(float64)
100-
if !ok {
101-
return nil, errors.New("second element of distribution element isn't float")
114+
weight := 1.0
115+
if len(distributionArray) >= 2 {
116+
distributionWeight, ok := distributionArray[1].(float64)
117+
if ok {
118+
// default the weight to 1 if not specified explicitly
119+
weight = distributionWeight
120+
}
102121
}
103122

104-
sumOfPercentages += int(percentage)
105-
106-
feDistributions = append(feDistributions, fractionalEvaluationDistribution{
107-
variant: variant,
108-
percentage: int(percentage),
109-
})
110-
}
111-
112-
if sumOfPercentages != 100 {
113-
return nil, fmt.Errorf("percentages must sum to 100, got: %d", sumOfPercentages)
123+
feDistributions.totalWeight += int(weight)
124+
feDistributions.weightedVariants[i] = fractionalEvaluationVariant{
125+
variant: variant,
126+
weight: int(weight),
127+
}
114128
}
115129

116130
return feDistributions, nil
117131
}
118132

119133
// distributeValue calculate hash for given hash key and find the bucket distributions belongs to
120-
func distributeValue(value string, feDistribution []fractionalEvaluationDistribution) string {
134+
func distributeValue(value string, feDistribution *fractionalEvaluationDistribution) string {
121135
hashValue := int32(murmur3.StringSum32(value))
122136
hashRatio := math.Abs(float64(hashValue)) / math.MaxInt32
123-
bucket := int(hashRatio * 100) // in range [0, 100]
137+
bucket := hashRatio * 100 // in range [0, 100]
124138

125-
rangeEnd := 0
126-
for _, dist := range feDistribution {
127-
rangeEnd += dist.percentage
139+
rangeEnd := float64(0)
140+
for _, weightedVariant := range feDistribution.weightedVariants {
141+
rangeEnd += weightedVariant.getPercentage(feDistribution.totalWeight)
128142
if bucket < rangeEnd {
129-
return dist.variant
143+
return weightedVariant.variant
130144
}
131145
}
132146

core/pkg/evaluator/fractional_test.go

Lines changed: 83 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import (
77
"github.com/open-feature/flagd/core/pkg/logger"
88
"github.com/open-feature/flagd/core/pkg/model"
99
"github.com/open-feature/flagd/core/pkg/store"
10+
"github.com/stretchr/testify/assert"
1011
)
1112

1213
func TestFractionalEvaluation(t *testing.T) {
@@ -318,7 +319,7 @@ func TestFractionalEvaluation(t *testing.T) {
318319
expectedValue: "#FF0000",
319320
expectedReason: model.DefaultReason,
320321
},
321-
"fallback to default variant if percentages don't sum to 100": {
322+
"get variant for non-percentage weight values": {
322323
flags: Flags{
323324
Flags: map[string]model.Flag{
324325
"headerColor": {
@@ -352,7 +353,41 @@ func TestFractionalEvaluation(t *testing.T) {
352353
},
353354
expectedVariant: "red",
354355
expectedValue: "#FF0000",
355-
expectedReason: model.DefaultReason,
356+
expectedReason: model.TargetingMatchReason,
357+
},
358+
"get variant for non-specified weight values": {
359+
flags: Flags{
360+
Flags: map[string]model.Flag{
361+
"headerColor": {
362+
State: "ENABLED",
363+
DefaultVariant: "red",
364+
Variants: map[string]any{
365+
"red": "#FF0000",
366+
"blue": "#0000FF",
367+
"green": "#00FF00",
368+
"yellow": "#FFFF00",
369+
},
370+
Targeting: []byte(`{
371+
"fractional": [
372+
{"var": "email"},
373+
[
374+
"red"
375+
],
376+
[
377+
"blue"
378+
]
379+
]
380+
}`),
381+
},
382+
},
383+
},
384+
flagKey: "headerColor",
385+
context: map[string]any{
386+
"email": "[email protected]",
387+
},
388+
expectedVariant: "red",
389+
expectedValue: "#FF0000",
390+
expectedReason: model.TargetingMatchReason,
356391
},
357392
"default to targetingKey if no bucket key provided": {
358393
flags: Flags{
@@ -579,3 +614,49 @@ func BenchmarkFractionalEvaluation(b *testing.B) {
579614
})
580615
}
581616
}
617+
618+
func Test_fractionalEvaluationVariant_getPercentage(t *testing.T) {
619+
type fields struct {
620+
variant string
621+
weight int
622+
}
623+
type args struct {
624+
totalWeight int
625+
}
626+
tests := []struct {
627+
name string
628+
fields fields
629+
args args
630+
want float64
631+
}{
632+
{
633+
name: "get percentage",
634+
fields: fields{
635+
weight: 10,
636+
},
637+
args: args{
638+
totalWeight: 20,
639+
},
640+
want: 50,
641+
},
642+
{
643+
name: "total weight 0",
644+
fields: fields{
645+
weight: 10,
646+
},
647+
args: args{
648+
totalWeight: 0,
649+
},
650+
want: 0,
651+
},
652+
}
653+
for _, tt := range tests {
654+
t.Run(tt.name, func(t *testing.T) {
655+
v := fractionalEvaluationVariant{
656+
variant: tt.fields.variant,
657+
weight: tt.fields.weight,
658+
}
659+
assert.Equalf(t, tt.want, v.getPercentage(tt.args.totalWeight), "getPercentage(%v)", tt.args.totalWeight)
660+
})
661+
}
662+
}

docs/reference/custom-operations/fractional-operation.md

Lines changed: 32 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,7 @@ OpenFeature allows clients to pass contextual information which can then be used
1717
{ "var": "email" }
1818
]
1919
},
20-
// Split definitions contain an array with a variant and percentage
21-
// Percentages must add up to 100
20+
// Split definitions contain an array with a variant and relative weights
2221
[
2322
// Must match a variant defined in the flag definition
2423
"red",
@@ -34,6 +33,31 @@ OpenFeature allows clients to pass contextual information which can then be used
3433
]
3534
```
3635

36+
If not specified, the default weight for a variant is set to `1`, so an alternative to the example above would be the following:
37+
38+
```js
39+
// Factional evaluation property name used in a targeting rule
40+
"fractional": [
41+
// Evaluation context property used to determine the split
42+
// Note using `cat` and `$flagd.flagKey` is the suggested default to seed your hash value and prevent bucketing collisions
43+
{
44+
"cat": [
45+
{ "var": "$flagd.flagKey" },
46+
{ "var": "email" }
47+
]
48+
},
49+
// Split definitions contain an array with a variant and relative weights
50+
[
51+
// Must match a variant defined in the flag definition
52+
"red"
53+
],
54+
[
55+
// Must match a variant defined in the flag definition
56+
"green"
57+
]
58+
]
59+
```
60+
3761
See the [headerColor](https://github.com/open-feature/flagd/blob/main/samples/example_flags.flagd.json#L88-#L133) flag.
3862
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`.
3963

@@ -44,7 +68,7 @@ The value retrieved by this expression is referred to as the "bucketing value".
4468
The bucketing value expression can be omitted, in which case a concatenation of the `targetingKey` and the `flagKey` will be used.
4569

4670
The `fractional` operation is a custom JsonLogic operation which deterministically selects a variant based on
47-
the defined distribution of each variant (as a percentage).
71+
the defined distribution of each variant (as a relative weight).
4872
This works by hashing ([murmur3](https://github.com/aappleby/smhasher/blob/master/src/MurmurHash3.cpp))
4973
the given data point, converting it into an int in the range [0, 99].
5074
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
5680
This rule should typically consist of a seed concatenated with a session variable to use from the evaluation context.
5781
This value should typically be something that remains consistent for the duration of a users session (e.g. email or session ID).
5882
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.
59-
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.
60-
There is no limit to the number of elements but the configured percentages must add up to 100.
83+
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.
84+
There is no limit to the number of elements.
85+
86+
> [!NOTE]
87+
> Older versions of the `fractional` operation were percentage based, and required all variants weights to sum to 100.
6188
6289
## Example
6390

0 commit comments

Comments
 (0)