From ce6fab9e6f6b71e85089746542b6cf44e7b2f428 Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Thu, 12 Mar 2026 15:18:55 +0100 Subject: [PATCH 01/16] high resolution fractional Signed-off-by: christian.lutnik --- .../flagd/core/targeting/Fractional.java | 74 ++++++++++++++++--- .../test/resources/fractional/boolean.json | 14 ++++ .../resources/fractional/largeDouble.json | 14 ++++ .../test/resources/fractional/largeInt.json | 14 ++++ .../fractional/selfContainedFractionalB.json | 2 +- .../src/test/resources/fractional/string.json | 14 ++++ 6 files changed, 119 insertions(+), 13 deletions(-) create mode 100644 tools/flagd-core/src/test/resources/fractional/boolean.json create mode 100644 tools/flagd-core/src/test/resources/fractional/largeDouble.json create mode 100644 tools/flagd-core/src/test/resources/fractional/largeInt.json create mode 100644 tools/flagd-core/src/test/resources/fractional/string.json diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index f36ebfffe..0936be475 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -3,9 +3,13 @@ import io.github.jamsesso.jsonlogic.JsonLogicException; import io.github.jamsesso.jsonlogic.evaluator.JsonLogicEvaluationException; import io.github.jamsesso.jsonlogic.evaluator.expressions.PreEvaluatedArgumentsExpression; +import java.math.BigDecimal; +import java.math.BigInteger; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; +import java.util.BitSet; import java.util.List; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -33,13 +37,19 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json // check optional string target in first arg Object arg1 = arguments.get(0); - final String bucketBy; + final byte[] bucketBy; final Object[] distributions; if (arg1 instanceof String) { - // first arg is a String, use for bucketing - bucketBy = (String) arg1; - + bucketBy = ((String) arg1).getBytes(StandardCharsets.UTF_8); + Object[] source = arguments.toArray(); + distributions = Arrays.copyOfRange(source, 1, source.length); + } else if (arg1 instanceof Number) { + bucketBy = numberToByteArray((Number) arg1); + Object[] source = arguments.toArray(); + distributions = Arrays.copyOfRange(source, 1, source.length); + } else if (arg1 instanceof Boolean) { + bucketBy = new byte[] {(byte) (((boolean) arg1) ? 1 : 0)}; Object[] source = arguments.toArray(); distributions = Arrays.copyOfRange(source, 1, source.length); } else { @@ -49,7 +59,7 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json return null; } - bucketBy = properties.getFlagKey() + properties.getTargetingKey(); + bucketBy = (properties.getFlagKey() + properties.getTargetingKey()).getBytes(StandardCharsets.UTF_8); distributions = arguments.toArray(); } @@ -71,16 +81,55 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json return distributeValue(bucketBy, propertyList, totalWeight, jsonPath); } + private byte[] numberToByteArray(Number number) { + if (number instanceof Integer) { + return new byte[] { + (byte) ((int) number >> 24), + (byte) ((int) number >> 16), + (byte) ((int) number >> 8), + (byte) ((int) number) + }; + } else if (number instanceof Double) { + return numberToByteArray(Double.doubleToLongBits((Double) number)); + } else if (number instanceof Long) { + return new byte[] { + (byte) ((long) number >> 56), + (byte) ((long) number >> 48), + (byte) ((long) number >> 40), + (byte) ((long) number >> 32), + (byte) ((long) number >> 24), + (byte) ((long) number >> 16), + (byte) ((long) number >> 8), + (byte) ((long) number) + }; + } else if (number instanceof BigInteger) { + return ((BigInteger) number).toByteArray(); + } else if (number instanceof Byte) { + return new byte[] {(byte) number}; + } else if (number instanceof Short) { + return new byte[] { + (byte) ((short) number >> 8), + (byte) ((short) number) + }; + } else if (number instanceof Float) { + return numberToByteArray(Float.floatToIntBits((Float) number)); + } else if (number instanceof BigDecimal) { + return numberToByteArray(Double.doubleToLongBits(number.doubleValue())); + } else { + throw new IllegalArgumentException("Unsupported number type: " + number.getClass()); + } + } + private static String distributeValue( - final String hashKey, final List propertyList, int totalWeight, String jsonPath) + final byte[] hashKey, final List propertyList, final int totalWeight, + final String jsonPath) throws JsonLogicEvaluationException { - byte[] bytes = hashKey.getBytes(StandardCharsets.UTF_8); - int mmrHash = MurmurHash3.hash32x86(bytes, 0, bytes.length, 0); - float bucket = Math.abs(mmrHash) * 1.0f / Integer.MAX_VALUE * 100; + long mmrHash = MurmurHash3.hash32x86(hashKey, 0, hashKey.length, 0); + int bucket = (int) (((mmrHash * totalWeight) >> 32) & 0xFFFFFFFFL); - float bucketSum = 0; + int bucketSum = 0; for (FractionProperty p : propertyList) { - bucketSum += p.getPercentage(totalWeight); + bucketSum += p.weight; if (bucket < bucketSum) { return p.getVariant(); @@ -122,7 +171,8 @@ protected final void finalize() { if (array.size() >= 2) { // second element must be a number if (!(array.get(1) instanceof Number)) { - throw new JsonLogicException("Second element of the fraction property is not a number", jsonPath); + throw new JsonLogicException("Second element of the fraction property is not a number", + jsonPath); } weight = ((Number) array.get(1)).intValue(); } else { diff --git a/tools/flagd-core/src/test/resources/fractional/boolean.json b/tools/flagd-core/src/test/resources/fractional/boolean.json new file mode 100644 index 000000000..fafc2d6fc --- /dev/null +++ b/tools/flagd-core/src/test/resources/fractional/boolean.json @@ -0,0 +1,14 @@ +{ + "rule": [ + true, + [ + "blue", + 50 + ], + [ + "green", + 70 + ] + ], + "result": "blue" +} diff --git a/tools/flagd-core/src/test/resources/fractional/largeDouble.json b/tools/flagd-core/src/test/resources/fractional/largeDouble.json new file mode 100644 index 000000000..a4b6e085f --- /dev/null +++ b/tools/flagd-core/src/test/resources/fractional/largeDouble.json @@ -0,0 +1,14 @@ +{ + "rule": [ + 9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999.9, + [ + "blue", + 50 + ], + [ + "green", + 70 + ] + ], + "result": "blue" +} diff --git a/tools/flagd-core/src/test/resources/fractional/largeInt.json b/tools/flagd-core/src/test/resources/fractional/largeInt.json new file mode 100644 index 000000000..4bb815044 --- /dev/null +++ b/tools/flagd-core/src/test/resources/fractional/largeInt.json @@ -0,0 +1,14 @@ +{ + "rule": [ + 9999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999999, + [ + "blue", + 50 + ], + [ + "green", + 70 + ] + ], + "result": "blue" +} diff --git a/tools/flagd-core/src/test/resources/fractional/selfContainedFractionalB.json b/tools/flagd-core/src/test/resources/fractional/selfContainedFractionalB.json index 2beb7e5be..e632f17d9 100644 --- a/tools/flagd-core/src/test/resources/fractional/selfContainedFractionalB.json +++ b/tools/flagd-core/src/test/resources/fractional/selfContainedFractionalB.json @@ -10,5 +10,5 @@ 50 ] ], - "result": "blue" + "result": "red" } diff --git a/tools/flagd-core/src/test/resources/fractional/string.json b/tools/flagd-core/src/test/resources/fractional/string.json new file mode 100644 index 000000000..8c55b68c1 --- /dev/null +++ b/tools/flagd-core/src/test/resources/fractional/string.json @@ -0,0 +1,14 @@ +{ + "rule": [ + "some string", + [ + "blue", + 50 + ], + [ + "green", + 70 + ] + ], + "result": "blue" +} From d42a51d50b20afe690f9d47d2d3381f4b49b3285 Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Thu, 12 Mar 2026 15:30:46 +0100 Subject: [PATCH 02/16] high resolution fractional Signed-off-by: christian.lutnik --- .../contrib/tools/flagd/core/targeting/Fractional.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index 0936be475..7c861ab02 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -124,8 +124,8 @@ private static String distributeValue( final byte[] hashKey, final List propertyList, final int totalWeight, final String jsonPath) throws JsonLogicEvaluationException { - long mmrHash = MurmurHash3.hash32x86(hashKey, 0, hashKey.length, 0); - int bucket = (int) (((mmrHash * totalWeight) >> 32) & 0xFFFFFFFFL); + int mmrHash = MurmurHash3.hash32x86(hashKey, 0, hashKey.length, 0); + int bucket = (int) (((mmrHash * (long)totalWeight) >> 32) & 0xFFFFFFFFL); int bucketSum = 0; for (FractionProperty p : propertyList) { From 411d6420d7f3c51368554437bbef59b1e3d13838 Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Thu, 12 Mar 2026 15:41:51 +0100 Subject: [PATCH 03/16] high resolution fractional Signed-off-by: christian.lutnik --- .../contrib/tools/flagd/core/targeting/OperatorTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java index cbd28a9e9..0408bf083 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java @@ -75,7 +75,7 @@ void testFlagPropertiesConstructor() { } @Test - void fractionalTestA() throws TargetingRuleException { + void fractionalTestB() throws TargetingRuleException { // given // fractional rule with email as expression key @@ -112,11 +112,11 @@ void fractionalTestA() throws TargetingRuleException { Object evalVariant = OPERATOR.apply("headerColor", targetingRule, new ImmutableContext(ctxData)); // then - assertEquals("yellow", evalVariant); + assertEquals("blue", evalVariant); } @Test - void fractionalTestB() throws TargetingRuleException { + void fractionalTestA() throws TargetingRuleException { // given // fractional rule with email as expression key @@ -153,7 +153,7 @@ void fractionalTestB() throws TargetingRuleException { Object evalVariant = OPERATOR.apply("headerColor", targetingRule, new ImmutableContext(ctxData)); // then - assertEquals("blue", evalVariant); + assertEquals("red", evalVariant); } @Test From 9586f8c48562c2d13478d52f6f7ba71c83da6707 Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Thu, 12 Mar 2026 15:46:04 +0100 Subject: [PATCH 04/16] high resolution fractional Signed-off-by: christian.lutnik --- .../flagd/core/targeting/Fractional.java | 28 +++++++++---------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index 7c861ab02..6d896d52b 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -84,23 +84,23 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json private byte[] numberToByteArray(Number number) { if (number instanceof Integer) { return new byte[] { - (byte) ((int) number >> 24), - (byte) ((int) number >> 16), - (byte) ((int) number >> 8), - (byte) ((int) number) + (byte) ((int) number >> 24), + (byte) ((int) number >> 16), + (byte) ((int) number >> 8), + (byte) ((int) number) }; } else if (number instanceof Double) { return numberToByteArray(Double.doubleToLongBits((Double) number)); } else if (number instanceof Long) { return new byte[] { - (byte) ((long) number >> 56), - (byte) ((long) number >> 48), - (byte) ((long) number >> 40), - (byte) ((long) number >> 32), - (byte) ((long) number >> 24), - (byte) ((long) number >> 16), - (byte) ((long) number >> 8), - (byte) ((long) number) + (byte) ((long) number >> 56), + (byte) ((long) number >> 48), + (byte) ((long) number >> 40), + (byte) ((long) number >> 32), + (byte) ((long) number >> 24), + (byte) ((long) number >> 16), + (byte) ((long) number >> 8), + (byte) ((long) number) }; } else if (number instanceof BigInteger) { return ((BigInteger) number).toByteArray(); @@ -108,8 +108,8 @@ private byte[] numberToByteArray(Number number) { return new byte[] {(byte) number}; } else if (number instanceof Short) { return new byte[] { - (byte) ((short) number >> 8), - (byte) ((short) number) + (byte) ((short) number >> 8), + (byte) ((short) number) }; } else if (number instanceof Float) { return numberToByteArray(Float.floatToIntBits((Float) number)); From 1ae9e2f9ab58270d53ea1641743ff0b19b68975b Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Thu, 12 Mar 2026 16:25:08 +0100 Subject: [PATCH 05/16] high resolution fractional Signed-off-by: christian.lutnik --- .../contrib/tools/flagd/core/targeting/Fractional.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index 6d896d52b..0fd1c05d2 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -125,7 +125,7 @@ private static String distributeValue( final String jsonPath) throws JsonLogicEvaluationException { int mmrHash = MurmurHash3.hash32x86(hashKey, 0, hashKey.length, 0); - int bucket = (int) (((mmrHash * (long)totalWeight) >> 32) & 0xFFFFFFFFL); + int bucket = (int) (((mmrHash * (long) totalWeight) >> 32) & 0xFFFFFFFFL); int bucketSum = 0; for (FractionProperty p : propertyList) { From a14ed15c03d52c8f39b5092aceb3826671f36b8f Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Thu, 12 Mar 2026 17:39:40 +0100 Subject: [PATCH 06/16] fix negative numbers Signed-off-by: christian.lutnik --- .../contrib/tools/flagd/core/targeting/Fractional.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index 0fd1c05d2..03c634b4c 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -125,7 +125,7 @@ private static String distributeValue( final String jsonPath) throws JsonLogicEvaluationException { int mmrHash = MurmurHash3.hash32x86(hashKey, 0, hashKey.length, 0); - int bucket = (int) (((mmrHash * (long) totalWeight) >> 32) & 0xFFFFFFFFL); + int bucket = Math.abs((int) ((mmrHash * (long) totalWeight) >> 32)); int bucketSum = 0; for (FractionProperty p : propertyList) { From c5f4e151afd00adde1517d5ec3473005851c487b Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Fri, 13 Mar 2026 12:24:49 +0100 Subject: [PATCH 07/16] fix bucketing algo Signed-off-by: christian.lutnik --- .../flagd/core/targeting/Fractional.java | 55 ++++++++------ .../flagd/core/targeting/FractionalTest.java | 76 +++++++++++++++++++ 2 files changed, 106 insertions(+), 25 deletions(-) diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index 03c634b4c..de6e5c159 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -84,23 +84,23 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json private byte[] numberToByteArray(Number number) { if (number instanceof Integer) { return new byte[] { - (byte) ((int) number >> 24), - (byte) ((int) number >> 16), - (byte) ((int) number >> 8), - (byte) ((int) number) + (byte) ((int) number >> 24), + (byte) ((int) number >> 16), + (byte) ((int) number >> 8), + (byte) ((int) number) }; } else if (number instanceof Double) { return numberToByteArray(Double.doubleToLongBits((Double) number)); } else if (number instanceof Long) { return new byte[] { - (byte) ((long) number >> 56), - (byte) ((long) number >> 48), - (byte) ((long) number >> 40), - (byte) ((long) number >> 32), - (byte) ((long) number >> 24), - (byte) ((long) number >> 16), - (byte) ((long) number >> 8), - (byte) ((long) number) + (byte) ((long) number >> 56), + (byte) ((long) number >> 48), + (byte) ((long) number >> 40), + (byte) ((long) number >> 32), + (byte) ((long) number >> 24), + (byte) ((long) number >> 16), + (byte) ((long) number >> 8), + (byte) ((long) number) }; } else if (number instanceof BigInteger) { return ((BigInteger) number).toByteArray(); @@ -108,8 +108,8 @@ private byte[] numberToByteArray(Number number) { return new byte[] {(byte) number}; } else if (number instanceof Short) { return new byte[] { - (byte) ((short) number >> 8), - (byte) ((short) number) + (byte) ((short) number >> 8), + (byte) ((short) number) }; } else if (number instanceof Float) { return numberToByteArray(Float.floatToIntBits((Float) number)); @@ -125,24 +125,36 @@ private static String distributeValue( final String jsonPath) throws JsonLogicEvaluationException { int mmrHash = MurmurHash3.hash32x86(hashKey, 0, hashKey.length, 0); - int bucket = Math.abs((int) ((mmrHash * (long) totalWeight) >> 32)); + return distributeValueFromHash(mmrHash, propertyList, totalWeight, jsonPath); + } + + static String distributeValueFromHash( + final int hash, final List propertyList, final int totalWeight, + final String jsonPath) + throws JsonLogicEvaluationException { + long longHash = Math.abs((long) hash); + if (hash < 0) { + // preserve the MSB (sign) of the hash, which would get lost in a typecast and in Math.abs + longHash = longHash | (1L << 31); + } + int bucket = Math.abs((int) ((longHash * totalWeight) >> 32)); int bucketSum = 0; for (FractionProperty p : propertyList) { bucketSum += p.weight; - if (bucket < bucketSum) { + if (bucket <= bucketSum) { return p.getVariant(); } } // this shall not be reached - throw new JsonLogicEvaluationException("Unable to find a correct bucket", jsonPath); + throw new JsonLogicEvaluationException("Unable to find a correct bucket for hash " + hash, jsonPath); } @Getter @SuppressWarnings({"checkstyle:NoFinalizer"}) - private static class FractionProperty { + static class FractionProperty { private final String variant; private final int weight; @@ -179,12 +191,5 @@ protected final void finalize() { weight = 1; } } - - float getPercentage(int totalWeight) { - if (weight == 0) { - return 0; - } - return (float) (weight * 100) / totalWeight; - } } } diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java index 897be5219..dc26f76f0 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java @@ -1,27 +1,42 @@ package dev.openfeature.contrib.tools.flagd.core.targeting; import static dev.openfeature.contrib.tools.flagd.core.targeting.Operator.*; +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Named.named; import static org.junit.jupiter.params.provider.Arguments.arguments; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; +import io.cucumber.java.sl.In; +import io.github.jamsesso.jsonlogic.JsonLogicException; import io.github.jamsesso.jsonlogic.evaluator.JsonLogicEvaluationException; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.converter.ArgumentConversionException; import org.junit.jupiter.params.converter.ConvertWith; import org.junit.jupiter.params.converter.TypedArgumentConverter; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.ArgumentsProvider; +import org.junit.jupiter.params.provider.ArgumentsSource; import org.junit.jupiter.params.provider.MethodSource; +import org.junit.jupiter.params.provider.ValueSource; +import org.junit.jupiter.params.support.ParameterDeclarations; class FractionalTest { @@ -47,6 +62,67 @@ void validate_emptyJson_targetingReturned(@ConvertWith(FileContentConverter.clas assertEquals(testData.result, evaluate); } + @ParameterizedTest + @ValueSource(ints = { + 0, + 1, + -1, + Integer.MAX_VALUE, + Integer.MAX_VALUE - 1, + Integer.MIN_VALUE, + Integer.MIN_VALUE + 1 + }) + void edgeCasesDoNotThrow(int hash) throws JsonLogicException { + int totalWeight = 8; + int buckets = 4; + List bucketsList = new ArrayList<>(buckets); + for (int i = 0; i < buckets; i++) { + bucketsList.add(new Fractional.FractionProperty(List.of("bucket" + i, totalWeight / buckets), "")); + } + + AtomicReference result = new AtomicReference<>(); + assertDoesNotThrow(() -> result.set(Fractional.distributeValueFromHash(hash, bucketsList, totalWeight, ""))); + + assertNotNull(result.get()); + assertTrue(result.get().startsWith("bucket")); + } + + @Test + void statistics() throws JsonLogicException { + int totalWeight = Integer.MAX_VALUE; + int buckets = 16; + int[] hits = new int[buckets]; + List bucketsList = new ArrayList<>(buckets); + int weight = totalWeight / buckets; + for (int i = 0; i < buckets - 1; i++) { + bucketsList.add(new Fractional.FractionProperty(List.of("" + i, weight), "")); + } + bucketsList.add( + new Fractional.FractionProperty(List.of("" + (buckets - 1), totalWeight - weight * (buckets - 1)), "") + ); + + for (long i = Integer.MIN_VALUE; i <= Integer.MAX_VALUE; i += 127) { + String bucketStr = Fractional.distributeValueFromHash((int) i, bucketsList, totalWeight, ""); + int bucket = Integer.parseInt(bucketStr); + hits[bucket]++; + } + + int min = Integer.MAX_VALUE; + int max = Integer.MIN_VALUE; + for (int i = 0; i < hits.length; i++) { + int current = hits[i]; + if (current < min) { + min = current; + } + if (current > max) { + max = current; + } + } + + int delta = max - min; + assertTrue(delta < 3, "Delta should be less than 5, but was " + delta); + } + public static Stream allFilesInDir() throws IOException { return Files.list(Paths.get("src", "test", "resources", "fractional")) .map(path -> arguments(named(path.getFileName().toString(), path))); From 195a836ddd835e8fb599e2a9ed7140944afbe39e Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Fri, 13 Mar 2026 12:26:04 +0100 Subject: [PATCH 08/16] fix bucketing algo Signed-off-by: christian.lutnik --- tools/flagd-core/src/test/resources/fractional/boolean.json | 2 +- tools/flagd-core/src/test/resources/fractional/largeDouble.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tools/flagd-core/src/test/resources/fractional/boolean.json b/tools/flagd-core/src/test/resources/fractional/boolean.json index fafc2d6fc..36182886f 100644 --- a/tools/flagd-core/src/test/resources/fractional/boolean.json +++ b/tools/flagd-core/src/test/resources/fractional/boolean.json @@ -10,5 +10,5 @@ 70 ] ], - "result": "blue" + "result": "green" } diff --git a/tools/flagd-core/src/test/resources/fractional/largeDouble.json b/tools/flagd-core/src/test/resources/fractional/largeDouble.json index a4b6e085f..b47cb6385 100644 --- a/tools/flagd-core/src/test/resources/fractional/largeDouble.json +++ b/tools/flagd-core/src/test/resources/fractional/largeDouble.json @@ -10,5 +10,5 @@ 70 ] ], - "result": "blue" + "result": "green" } From 40c6ee7296f61c6173683d4ac6d7bc4181c0d7f7 Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Fri, 13 Mar 2026 12:29:21 +0100 Subject: [PATCH 09/16] fix bucketing algo Signed-off-by: christian.lutnik --- .../flagd/core/targeting/Fractional.java | 41 ++++++++----------- .../flagd/core/targeting/FractionalTest.java | 23 +++-------- 2 files changed, 23 insertions(+), 41 deletions(-) diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index de6e5c159..234c8a11e 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -5,11 +5,9 @@ import io.github.jamsesso.jsonlogic.evaluator.expressions.PreEvaluatedArgumentsExpression; import java.math.BigDecimal; import java.math.BigInteger; -import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; -import java.util.BitSet; import java.util.List; import lombok.Getter; import lombok.extern.slf4j.Slf4j; @@ -84,33 +82,30 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json private byte[] numberToByteArray(Number number) { if (number instanceof Integer) { return new byte[] { - (byte) ((int) number >> 24), - (byte) ((int) number >> 16), - (byte) ((int) number >> 8), - (byte) ((int) number) + (byte) ((int) number >> 24), + (byte) ((int) number >> 16), + (byte) ((int) number >> 8), + (byte) ((int) number) }; } else if (number instanceof Double) { return numberToByteArray(Double.doubleToLongBits((Double) number)); } else if (number instanceof Long) { return new byte[] { - (byte) ((long) number >> 56), - (byte) ((long) number >> 48), - (byte) ((long) number >> 40), - (byte) ((long) number >> 32), - (byte) ((long) number >> 24), - (byte) ((long) number >> 16), - (byte) ((long) number >> 8), - (byte) ((long) number) + (byte) ((long) number >> 56), + (byte) ((long) number >> 48), + (byte) ((long) number >> 40), + (byte) ((long) number >> 32), + (byte) ((long) number >> 24), + (byte) ((long) number >> 16), + (byte) ((long) number >> 8), + (byte) ((long) number) }; } else if (number instanceof BigInteger) { return ((BigInteger) number).toByteArray(); } else if (number instanceof Byte) { return new byte[] {(byte) number}; } else if (number instanceof Short) { - return new byte[] { - (byte) ((short) number >> 8), - (byte) ((short) number) - }; + return new byte[] {(byte) ((short) number >> 8), (byte) ((short) number)}; } else if (number instanceof Float) { return numberToByteArray(Float.floatToIntBits((Float) number)); } else if (number instanceof BigDecimal) { @@ -121,7 +116,9 @@ private byte[] numberToByteArray(Number number) { } private static String distributeValue( - final byte[] hashKey, final List propertyList, final int totalWeight, + final byte[] hashKey, + final List propertyList, + final int totalWeight, final String jsonPath) throws JsonLogicEvaluationException { int mmrHash = MurmurHash3.hash32x86(hashKey, 0, hashKey.length, 0); @@ -129,8 +126,7 @@ private static String distributeValue( } static String distributeValueFromHash( - final int hash, final List propertyList, final int totalWeight, - final String jsonPath) + final int hash, final List propertyList, final int totalWeight, final String jsonPath) throws JsonLogicEvaluationException { long longHash = Math.abs((long) hash); if (hash < 0) { @@ -183,8 +179,7 @@ protected final void finalize() { if (array.size() >= 2) { // second element must be a number if (!(array.get(1) instanceof Number)) { - throw new JsonLogicException("Second element of the fraction property is not a number", - jsonPath); + throw new JsonLogicException("Second element of the fraction property is not a number", jsonPath); } weight = ((Number) array.get(1)).intValue(); } else { diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java index dc26f76f0..cb5404f9e 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java @@ -10,7 +10,6 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; -import io.cucumber.java.sl.In; import io.github.jamsesso.jsonlogic.JsonLogicException; import io.github.jamsesso.jsonlogic.evaluator.JsonLogicEvaluationException; import java.io.IOException; @@ -26,17 +25,12 @@ import java.util.stream.Collectors; import java.util.stream.Stream; import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtensionContext; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.converter.ArgumentConversionException; import org.junit.jupiter.params.converter.ConvertWith; import org.junit.jupiter.params.converter.TypedArgumentConverter; -import org.junit.jupiter.params.provider.Arguments; -import org.junit.jupiter.params.provider.ArgumentsProvider; -import org.junit.jupiter.params.provider.ArgumentsSource; import org.junit.jupiter.params.provider.MethodSource; import org.junit.jupiter.params.provider.ValueSource; -import org.junit.jupiter.params.support.ParameterDeclarations; class FractionalTest { @@ -63,15 +57,7 @@ void validate_emptyJson_targetingReturned(@ConvertWith(FileContentConverter.clas } @ParameterizedTest - @ValueSource(ints = { - 0, - 1, - -1, - Integer.MAX_VALUE, - Integer.MAX_VALUE - 1, - Integer.MIN_VALUE, - Integer.MIN_VALUE + 1 - }) + @ValueSource(ints = {0, 1, -1, Integer.MAX_VALUE, Integer.MAX_VALUE - 1, Integer.MIN_VALUE, Integer.MIN_VALUE + 1}) void edgeCasesDoNotThrow(int hash) throws JsonLogicException { int totalWeight = 8; int buckets = 4; @@ -98,8 +84,7 @@ void statistics() throws JsonLogicException { bucketsList.add(new Fractional.FractionProperty(List.of("" + i, weight), "")); } bucketsList.add( - new Fractional.FractionProperty(List.of("" + (buckets - 1), totalWeight - weight * (buckets - 1)), "") - ); + new Fractional.FractionProperty(List.of("" + (buckets - 1), totalWeight - weight * (buckets - 1)), "")); for (long i = Integer.MIN_VALUE; i <= Integer.MAX_VALUE; i += 127) { String bucketStr = Fractional.distributeValueFromHash((int) i, bucketsList, totalWeight, ""); @@ -120,7 +105,9 @@ void statistics() throws JsonLogicException { } int delta = max - min; - assertTrue(delta < 3, "Delta should be less than 5, but was " + delta); + assertTrue( + delta < 3, + "Delta should be less than 3, but was " + delta + ". Distributions: " + Arrays.toString(hits)); } public static Stream allFilesInDir() throws IOException { From f1a2ad188d610585cecc1732cb0b22f259d02cb2 Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Mon, 16 Mar 2026 10:06:15 +0100 Subject: [PATCH 10/16] fix bucketing algo Signed-off-by: christian.lutnik --- .../contrib/providers/flagd/e2e/RunInProcessTest.java | 7 ++++--- .../contrib/tools/flagd/core/targeting/Fractional.java | 8 ++------ 2 files changed, 6 insertions(+), 9 deletions(-) diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java index c694aa9ef..931ff2404 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java @@ -12,6 +12,7 @@ import org.junit.platform.suite.api.IncludeEngines; import org.junit.platform.suite.api.IncludeTags; import org.junit.platform.suite.api.SelectDirectories; +import org.junit.platform.suite.api.SelectFile; import org.junit.platform.suite.api.Suite; import org.testcontainers.junit.jupiter.Testcontainers; @@ -21,14 +22,14 @@ @Order(value = Integer.MAX_VALUE) @Suite @IncludeEngines("cucumber") -@SelectDirectories("test-harness/gherkin") +//@SelectDirectories("test-harness/gherkin") // if you want to run just one feature file, use the following line instead of @SelectDirectories -// @SelectFile("test-harness/gherkin/selector.feature") +@SelectFile("test-harness/gherkin/targeting.feature") @ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") @ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps") @ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") @IncludeTags("in-process") -@ExcludeTags({"unixsocket"}) +@ExcludeTags({"unixsocket", "fractional-v1"}) @Testcontainers public class RunInProcessTest { diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index 234c8a11e..d7e06f3a4 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -128,18 +128,14 @@ private static String distributeValue( static String distributeValueFromHash( final int hash, final List propertyList, final int totalWeight, final String jsonPath) throws JsonLogicEvaluationException { - long longHash = Math.abs((long) hash); - if (hash < 0) { - // preserve the MSB (sign) of the hash, which would get lost in a typecast and in Math.abs - longHash = longHash | (1L << 31); - } + long longHash = Integer.toUnsignedLong(hash); int bucket = Math.abs((int) ((longHash * totalWeight) >> 32)); int bucketSum = 0; for (FractionProperty p : propertyList) { bucketSum += p.weight; - if (bucket <= bucketSum) { + if (bucket < bucketSum) { return p.getVariant(); } } From 4fda79b22ea0e8825ff7765077fb6c163f7500af Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Mon, 16 Mar 2026 10:58:12 +0100 Subject: [PATCH 11/16] fix bucketing algo Signed-off-by: christian.lutnik --- .../providers/flagd/e2e/RunInProcessTest.java | 5 +- .../flagd/core/targeting/FractionalTest.java | 70 +++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java index 931ff2404..ab758d5e1 100644 --- a/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java +++ b/providers/flagd/src/test/java/dev/openfeature/contrib/providers/flagd/e2e/RunInProcessTest.java @@ -12,7 +12,6 @@ import org.junit.platform.suite.api.IncludeEngines; import org.junit.platform.suite.api.IncludeTags; import org.junit.platform.suite.api.SelectDirectories; -import org.junit.platform.suite.api.SelectFile; import org.junit.platform.suite.api.Suite; import org.testcontainers.junit.jupiter.Testcontainers; @@ -22,9 +21,9 @@ @Order(value = Integer.MAX_VALUE) @Suite @IncludeEngines("cucumber") -//@SelectDirectories("test-harness/gherkin") +@SelectDirectories("test-harness/gherkin") // if you want to run just one feature file, use the following line instead of @SelectDirectories -@SelectFile("test-harness/gherkin/targeting.feature") +// @SelectFile("test-harness/gherkin/selector.feature") @ConfigurationParameter(key = PLUGIN_PROPERTY_NAME, value = "pretty") @ConfigurationParameter(key = GLUE_PROPERTY_NAME, value = "dev.openfeature.contrib.providers.flagd.e2e.steps") @ConfigurationParameter(key = OBJECT_FACTORY_PROPERTY_NAME, value = "io.cucumber.picocontainer.PicoFactory") diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java index cb5404f9e..895c1289a 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java @@ -21,9 +21,12 @@ import java.util.HashMap; import java.util.List; import java.util.Map; +import java.util.Random; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import java.util.stream.Stream; +import org.apache.commons.codec.digest.MurmurHash3; +import org.apache.commons.lang3.RandomStringUtils; import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.converter.ArgumentConversionException; @@ -56,6 +59,73 @@ void validate_emptyJson_targetingReturned(@ConvertWith(FileContentConverter.clas assertEquals(testData.result, evaluate); } + @Test + void b(){ + byte[] bytes = new byte[]{49, 71, 65, 71, 87, 114, 90, 89, 104, 67, 75, 74, 69, 52, 78, 114, 67, 86, 118, 114, 99, 49, 90, 65, 110, 115, 57, 87, 86, 117, 57, 114, 70, 101, 112, 83, 102, 83, 117, 85, 69, 71, 97, 122, 111, 80, 67, 52, 121, 99, 119, 90, 87, 98, 113, 66, 84, 106, 122, 49, 57, 74, 117, 87, 71, 98, 77, 99, 107, 89, 110, 88, 72, 78, 57, 86, 102, 55, 53, 113, 89, 121, 57, 74, 51, 54, 115, 77, 86, 108, 73, 73, 122, 66, 120, 53, 66, 70, 76, 51, 69, 55, 118, 117, 56, 53, 68, 112, 116, 70, 120, 65, 67, 122, 77, 72, 48, 98, 112, 100, 117, 88, 88, 80, 54, 118, 65, 90}; + String s = new String(bytes); + System.out.println(s); + System.out.println(MurmurHash3.hash32x86(s.getBytes(), 0, s.getBytes().length, 0)); + } + + @Test + void c(){ + /* + - Single character: `"i"` → hash 2165993515, bucket_value 50 (first of upper bucket in 50/50) +  - Two characters: `"bx"` → hash 2106591975, bucket_value 49 (last of lower bucket in 50/50) +  - Two characters: `"cd"` → hash 2158755732, bucket_value 50 (first of upper bucket) + */ + System.out.println(Integer.toUnsignedLong(MurmurHash3.hash32x86("i".getBytes(), 0, "i".getBytes().length, 0))); + System.out.println(Integer.toUnsignedLong(MurmurHash3.hash32x86("bx".getBytes(), 0, "bx".getBytes().length, 0))); + System.out.println(Integer.toUnsignedLong(MurmurHash3.hash32x86("cd".getBytes(), 0, "cd".getBytes().length, 0))); + } + + @Test + void d() throws JsonLogicException { + int buckets = 2; + int totalWeight = 100; + int weight = totalWeight / buckets; + List bucketsList = new ArrayList<>(buckets); + for (int i = 0; i < buckets - 1; i++) { + bucketsList.add(new Fractional.FractionProperty(List.of("" + i, weight), "")); + } + bucketsList.add( + new Fractional.FractionProperty(List.of("" + (buckets - 1), totalWeight - weight * (buckets - 1)), "")); + + Fractional.distributeValueFromHash((int)2165993515L,bucketsList, totalWeight, ""); + } + + @Test + void a() { + Random random = new Random(0); + System.out.println("starting"); + boolean min = false; + boolean max = false; + boolean minusOne = false; + boolean plusOne = false; + boolean zero = false; + while (!min || !max || !minusOne || !plusOne || !zero) { + byte[] bytes = RandomStringUtils.random(128, '!', '}', true, true, null, random).getBytes(); + + int mmrHash = MurmurHash3.hash32x86(bytes, 0, bytes.length, 0); + if (mmrHash == Integer.MIN_VALUE) { + System.out.println("c is min value = " + new String(bytes)); + min = true; + } else if (mmrHash == Integer.MAX_VALUE) { + max = true; + System.out.println("c is max value = " + new String(bytes)); + } else if (mmrHash == -1) { + minusOne = true; + System.out.println("c is -1 = " + new String(bytes)); + } else if (mmrHash == 1) { + plusOne = true; + System.out.println("c is 1 = " + new String(bytes)); + } else if (mmrHash == 0) { + zero = true; + System.out.println("c is 0 = " + new String(bytes)); + } + } + } + @ParameterizedTest @ValueSource(ints = {0, 1, -1, Integer.MAX_VALUE, Integer.MAX_VALUE - 1, Integer.MIN_VALUE, Integer.MIN_VALUE + 1}) void edgeCasesDoNotThrow(int hash) throws JsonLogicException { From d1f4bdcea77060d2d2534ee60cbb963941e0d95b Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Mon, 16 Mar 2026 11:39:15 +0100 Subject: [PATCH 12/16] fix bucketing algo Signed-off-by: christian.lutnik --- .../flagd/core/targeting/FractionalTest.java | 113 +----------------- 1 file changed, 3 insertions(+), 110 deletions(-) diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java index 895c1289a..ce5d2e82c 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java @@ -1,6 +1,8 @@ package dev.openfeature.contrib.tools.flagd.core.targeting; -import static dev.openfeature.contrib.tools.flagd.core.targeting.Operator.*; +import static dev.openfeature.contrib.tools.flagd.core.targeting.Operator.FLAGD_PROPS_KEY; +import static dev.openfeature.contrib.tools.flagd.core.targeting.Operator.FLAG_KEY; +import static dev.openfeature.contrib.tools.flagd.core.targeting.Operator.TARGET_KEY; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotNull; @@ -17,17 +19,12 @@ import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; -import java.util.Arrays; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.Random; import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import java.util.stream.Stream; -import org.apache.commons.codec.digest.MurmurHash3; -import org.apache.commons.lang3.RandomStringUtils; -import org.junit.jupiter.api.Test; import org.junit.jupiter.params.ParameterizedTest; import org.junit.jupiter.params.converter.ArgumentConversionException; import org.junit.jupiter.params.converter.ConvertWith; @@ -59,73 +56,6 @@ void validate_emptyJson_targetingReturned(@ConvertWith(FileContentConverter.clas assertEquals(testData.result, evaluate); } - @Test - void b(){ - byte[] bytes = new byte[]{49, 71, 65, 71, 87, 114, 90, 89, 104, 67, 75, 74, 69, 52, 78, 114, 67, 86, 118, 114, 99, 49, 90, 65, 110, 115, 57, 87, 86, 117, 57, 114, 70, 101, 112, 83, 102, 83, 117, 85, 69, 71, 97, 122, 111, 80, 67, 52, 121, 99, 119, 90, 87, 98, 113, 66, 84, 106, 122, 49, 57, 74, 117, 87, 71, 98, 77, 99, 107, 89, 110, 88, 72, 78, 57, 86, 102, 55, 53, 113, 89, 121, 57, 74, 51, 54, 115, 77, 86, 108, 73, 73, 122, 66, 120, 53, 66, 70, 76, 51, 69, 55, 118, 117, 56, 53, 68, 112, 116, 70, 120, 65, 67, 122, 77, 72, 48, 98, 112, 100, 117, 88, 88, 80, 54, 118, 65, 90}; - String s = new String(bytes); - System.out.println(s); - System.out.println(MurmurHash3.hash32x86(s.getBytes(), 0, s.getBytes().length, 0)); - } - - @Test - void c(){ - /* - - Single character: `"i"` → hash 2165993515, bucket_value 50 (first of upper bucket in 50/50) -  - Two characters: `"bx"` → hash 2106591975, bucket_value 49 (last of lower bucket in 50/50) -  - Two characters: `"cd"` → hash 2158755732, bucket_value 50 (first of upper bucket) - */ - System.out.println(Integer.toUnsignedLong(MurmurHash3.hash32x86("i".getBytes(), 0, "i".getBytes().length, 0))); - System.out.println(Integer.toUnsignedLong(MurmurHash3.hash32x86("bx".getBytes(), 0, "bx".getBytes().length, 0))); - System.out.println(Integer.toUnsignedLong(MurmurHash3.hash32x86("cd".getBytes(), 0, "cd".getBytes().length, 0))); - } - - @Test - void d() throws JsonLogicException { - int buckets = 2; - int totalWeight = 100; - int weight = totalWeight / buckets; - List bucketsList = new ArrayList<>(buckets); - for (int i = 0; i < buckets - 1; i++) { - bucketsList.add(new Fractional.FractionProperty(List.of("" + i, weight), "")); - } - bucketsList.add( - new Fractional.FractionProperty(List.of("" + (buckets - 1), totalWeight - weight * (buckets - 1)), "")); - - Fractional.distributeValueFromHash((int)2165993515L,bucketsList, totalWeight, ""); - } - - @Test - void a() { - Random random = new Random(0); - System.out.println("starting"); - boolean min = false; - boolean max = false; - boolean minusOne = false; - boolean plusOne = false; - boolean zero = false; - while (!min || !max || !minusOne || !plusOne || !zero) { - byte[] bytes = RandomStringUtils.random(128, '!', '}', true, true, null, random).getBytes(); - - int mmrHash = MurmurHash3.hash32x86(bytes, 0, bytes.length, 0); - if (mmrHash == Integer.MIN_VALUE) { - System.out.println("c is min value = " + new String(bytes)); - min = true; - } else if (mmrHash == Integer.MAX_VALUE) { - max = true; - System.out.println("c is max value = " + new String(bytes)); - } else if (mmrHash == -1) { - minusOne = true; - System.out.println("c is -1 = " + new String(bytes)); - } else if (mmrHash == 1) { - plusOne = true; - System.out.println("c is 1 = " + new String(bytes)); - } else if (mmrHash == 0) { - zero = true; - System.out.println("c is 0 = " + new String(bytes)); - } - } - } - @ParameterizedTest @ValueSource(ints = {0, 1, -1, Integer.MAX_VALUE, Integer.MAX_VALUE - 1, Integer.MIN_VALUE, Integer.MIN_VALUE + 1}) void edgeCasesDoNotThrow(int hash) throws JsonLogicException { @@ -143,43 +73,6 @@ void edgeCasesDoNotThrow(int hash) throws JsonLogicException { assertTrue(result.get().startsWith("bucket")); } - @Test - void statistics() throws JsonLogicException { - int totalWeight = Integer.MAX_VALUE; - int buckets = 16; - int[] hits = new int[buckets]; - List bucketsList = new ArrayList<>(buckets); - int weight = totalWeight / buckets; - for (int i = 0; i < buckets - 1; i++) { - bucketsList.add(new Fractional.FractionProperty(List.of("" + i, weight), "")); - } - bucketsList.add( - new Fractional.FractionProperty(List.of("" + (buckets - 1), totalWeight - weight * (buckets - 1)), "")); - - for (long i = Integer.MIN_VALUE; i <= Integer.MAX_VALUE; i += 127) { - String bucketStr = Fractional.distributeValueFromHash((int) i, bucketsList, totalWeight, ""); - int bucket = Integer.parseInt(bucketStr); - hits[bucket]++; - } - - int min = Integer.MAX_VALUE; - int max = Integer.MIN_VALUE; - for (int i = 0; i < hits.length; i++) { - int current = hits[i]; - if (current < min) { - min = current; - } - if (current > max) { - max = current; - } - } - - int delta = max - min; - assertTrue( - delta < 3, - "Delta should be less than 3, but was " + delta + ". Distributions: " + Arrays.toString(hits)); - } - public static Stream allFilesInDir() throws IOException { return Files.list(Paths.get("src", "test", "resources", "fractional")) .map(path -> arguments(named(path.getFileName().toString(), path))); From a78a6c680d5623b0d696ce309024971922db939b Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Mon, 16 Mar 2026 11:45:34 +0100 Subject: [PATCH 13/16] fix bucketing algo Signed-off-by: christian.lutnik --- .../flagd/core/targeting/FractionalTest.java | 24 ------------------- 1 file changed, 24 deletions(-) diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java index ce5d2e82c..6bdd45b29 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/FractionalTest.java @@ -3,26 +3,20 @@ import static dev.openfeature.contrib.tools.flagd.core.targeting.Operator.FLAGD_PROPS_KEY; import static dev.openfeature.contrib.tools.flagd.core.targeting.Operator.FLAG_KEY; import static dev.openfeature.contrib.tools.flagd.core.targeting.Operator.TARGET_KEY; -import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; -import static org.junit.jupiter.api.Assertions.assertTrue; import static org.junit.jupiter.api.Named.named; import static org.junit.jupiter.params.provider.Arguments.arguments; import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.databind.ObjectMapper; -import io.github.jamsesso.jsonlogic.JsonLogicException; import io.github.jamsesso.jsonlogic.evaluator.JsonLogicEvaluationException; import java.io.IOException; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; -import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; -import java.util.concurrent.atomic.AtomicReference; import java.util.stream.Collectors; import java.util.stream.Stream; import org.junit.jupiter.params.ParameterizedTest; @@ -30,7 +24,6 @@ import org.junit.jupiter.params.converter.ConvertWith; import org.junit.jupiter.params.converter.TypedArgumentConverter; import org.junit.jupiter.params.provider.MethodSource; -import org.junit.jupiter.params.provider.ValueSource; class FractionalTest { @@ -56,23 +49,6 @@ void validate_emptyJson_targetingReturned(@ConvertWith(FileContentConverter.clas assertEquals(testData.result, evaluate); } - @ParameterizedTest - @ValueSource(ints = {0, 1, -1, Integer.MAX_VALUE, Integer.MAX_VALUE - 1, Integer.MIN_VALUE, Integer.MIN_VALUE + 1}) - void edgeCasesDoNotThrow(int hash) throws JsonLogicException { - int totalWeight = 8; - int buckets = 4; - List bucketsList = new ArrayList<>(buckets); - for (int i = 0; i < buckets; i++) { - bucketsList.add(new Fractional.FractionProperty(List.of("bucket" + i, totalWeight / buckets), "")); - } - - AtomicReference result = new AtomicReference<>(); - assertDoesNotThrow(() -> result.set(Fractional.distributeValueFromHash(hash, bucketsList, totalWeight, ""))); - - assertNotNull(result.get()); - assertTrue(result.get().startsWith("bucket")); - } - public static Stream allFilesInDir() throws IOException { return Files.list(Paths.get("src", "test", "resources", "fractional")) .map(path -> arguments(named(path.getFileName().toString(), path))); From 602238ca16cce0b660198410ba4a6b39ad128dfa Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Mon, 16 Mar 2026 16:55:47 +0100 Subject: [PATCH 14/16] fix stuff Signed-off-by: christian.lutnik --- .../contrib/tools/flagd/core/targeting/OperatorTest.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java index 0408bf083..17ff01c97 100644 --- a/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java +++ b/tools/flagd-core/src/test/java/dev/openfeature/contrib/tools/flagd/core/targeting/OperatorTest.java @@ -153,7 +153,7 @@ void fractionalTestA() throws TargetingRuleException { Object evalVariant = OPERATOR.apply("headerColor", targetingRule, new ImmutableContext(ctxData)); // then - assertEquals("red", evalVariant); + assertEquals("yellow", evalVariant); } @Test From 3eee9492be88859c9478d5b4bdeca544f26eef8b Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Mon, 16 Mar 2026 17:09:46 +0100 Subject: [PATCH 15/16] fix stuff Signed-off-by: christian.lutnik --- .../main/resources/flagd/schemas/flags.json | 205 ++++++++++++------ 1 file changed, 142 insertions(+), 63 deletions(-) diff --git a/tools/flagd-core/src/main/resources/flagd/schemas/flags.json b/tools/flagd-core/src/main/resources/flagd/schemas/flags.json index 6e045b654..cff1aab81 100644 --- a/tools/flagd-core/src/main/resources/flagd/schemas/flags.json +++ b/tools/flagd-core/src/main/resources/flagd/schemas/flags.json @@ -1,11 +1,9 @@ { "$id": "https://flagd.dev/schema/v0/flags.json", "$schema": "http://json-schema.org/draft-07/schema#", - "title": "flagd Flag Configuration", - "description": "Defines flags for use in flagd, including typed variants and rules.", - "type": "object", - "properties": { - "flags": { + "$ref": "#/definitions/providerConfig", + "definitions": { + "flagsMap": { "title": "Flags", "description": "Top-level flags object. All flags are defined here.", "type": "object", @@ -13,61 +11,113 @@ "additionalProperties": false, "patternProperties": { "^.{1,}$": { - "oneOf": [ - { - "title": "Boolean flag", - "description": "A flag having boolean values.", - "$ref": "#/definitions/booleanFlag" - }, - { - "title": "String flag", - "description": "A flag having string values.", - "$ref": "#/definitions/stringFlag" + "$ref": "#/definitions/anyFlag" + } + } + }, + "flagsArray": { + "title": "Flags", + "description": "Top-level flags array. All flags are defined here.", + "type": "array", + "items": { + "allOf": [ + { + "$ref": "#/definitions/anyFlag" + }, + { + "type": "object", + "properties": { + "key": { + "description": "Key of the flag: uniquely identifies this flag within it's flagSet", + "type": "string", + "minLength": 1 + } }, - { - "title": "Numeric flag", - "description": "A flag having numeric values.", - "$ref": "#/definitions/numberFlag" + "required": [ + "key" + ] + } + ] + } + }, + "baseConfig": { + "title": "flagd Flag Configuration", + "description": "Defines flags for use in flagd providers, including typed variants and rules.", + "type": "object", + "properties": { + "$evaluators": { + "title": "Evaluators", + "description": "Reusable targeting rules that can be referenced with \"$ref\": \"myRule\" in multiple flags.", + "type": "object", + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": { + "$comment": "this relative ref means that targeting.json MUST be in the same dir, or available on the same HTTP path", + "$ref": "./targeting.json" + } + } + }, + "metadata": { + "title": "Flag Set Metadata", + "description": "Metadata about the flag set, with keys of type string, and values of type boolean, string, or number.", + "properties": { + "flagSetId": { + "description": "The unique identifier for the flag set.", + "type": "string" }, - { - "title": "Object flag", - "description": "A flag having arbitrary object values.", - "$ref": "#/definitions/objectFlag" + "version": { + "description": "The version of the flag set.", + "type": "string" } - ] + }, + "$ref": "#/definitions/metadata" } } }, - "$evaluators": { - "title": "Evaluators", - "description": "Reusable targeting rules that can be referenced with \"$ref\": \"myRule\" in multiple flags.", + "providerConfig": { + "description": "Defines flags for use in providers (not flagd), including typed variants and rules.", "type": "object", - "additionalProperties": false, - "patternProperties": { - "^.{1,}$": { - "$comment": "this relative ref means that targeting.json MUST be in the same dir, or available on the same HTTP path", - "$ref": "./targeting.json" + "allOf": [ + { + "$ref": "#/definitions/baseConfig" } - } - }, - "metadata": { - "title": "Flag Set Metadata", - "description": "Metadata about the flag set, with keys of type string, and values of type boolean, string, or number.", + ], "properties": { - "flagSetId": { - "description": "The unique identifier for the flag set.", - "type": "string" - }, - "version": { - "description": "The version of the flag set.", - "type": "string" + "flags": { + "$ref": "#/definitions/flagsMap" } }, - "$ref": "#/definitions/metadata" - } - }, - "definitions": { - "flag": { + "required": [ + "flags" + ] + }, + "flagdConfig": { + "description": "Defines flags for use in the flagd daemon (a superset of what's available in providers), including typed variants and rules. Flags can be defined as an array or an object.", + "type": "object", + "allOf": [ + { + "$ref": "#/definitions/baseConfig" + }, + { + "properties": { + "flags": { + "oneOf": [ + { + "$ref": "#/definitions/flagsMap" + }, + { + "$ref": "#/definitions/flagsArray" + } + ] + } + } + } + ], + "required": [ + "flags" + ] + }, + "baseFlag": { "$comment": "base flag object; no title/description here, allows for better UX, keep it in the overrides", "type": "object", "properties": { @@ -82,8 +132,11 @@ }, "defaultVariant": { "title": "Default Variant", - "description": "The variant to serve if no dynamic targeting applies (including if the targeting returns null).", - "type": "string" + "description": "The variant to serve if no dynamic targeting applies (including if the targeting returns null). Set to null to use code-defined default.", + "type": [ + "string", + "null" + ] }, "targeting": { "$ref": "./targeting.json" @@ -92,11 +145,19 @@ "title": "Flag Metadata", "description": "Metadata about an individual feature flag, with keys of type string, and values of type boolean, string, or number.", "$ref": "#/definitions/metadata" + }, + "variants": { + "type": "object", + "minProperties": 1, + "additionalProperties": false, + "patternProperties": { + "^.{1,}$": {} + } } }, "required": [ "state", - "defaultVariant" + "variants" ] }, "booleanVariants": { @@ -109,10 +170,6 @@ "^.{1,}$": { "type": "boolean" } - }, - "default": { - "true": true, - "false": false } } } @@ -159,11 +216,29 @@ } } }, + "anyFlag": { + "anyOf": [ + { + "$ref": "#/definitions/booleanFlag" + }, + { + "$ref": "#/definitions/numberFlag" + }, + { + "$ref": "#/definitions/stringFlag" + }, + { + "$ref": "#/definitions/objectFlag" + } + ] + }, "booleanFlag": { "$comment": "merge the variants with the base flag to build our typed flags", + "title": "Boolean flag", + "description": "A flag having boolean values.", "allOf": [ { - "$ref": "#/definitions/flag" + "$ref": "#/definitions/baseFlag" }, { "$ref": "#/definitions/booleanVariants" @@ -171,9 +246,11 @@ ] }, "stringFlag": { + "title": "String flag", + "description": "A flag having string values.", "allOf": [ { - "$ref": "#/definitions/flag" + "$ref": "#/definitions/baseFlag" }, { "$ref": "#/definitions/stringVariants" @@ -181,9 +258,11 @@ ] }, "numberFlag": { + "title": "Numeric flag", + "description": "A flag having numeric values.", "allOf": [ { - "$ref": "#/definitions/flag" + "$ref": "#/definitions/baseFlag" }, { "$ref": "#/definitions/numberVariants" @@ -191,9 +270,11 @@ ] }, "objectFlag": { + "title": "Object flag", + "description": "A flag having arbitrary object values.", "allOf": [ { - "$ref": "#/definitions/flag" + "$ref": "#/definitions/baseFlag" }, { "$ref": "#/definitions/objectVariants" @@ -203,14 +284,12 @@ "metadata": { "type": "object", "additionalProperties": { - "description": "Any additional key/value pair with value of type boolean, string, or number.", "type": [ "string", "number", "boolean" ] - }, - "required": [] + } } } } From 6738b17694a1b7072e509ef4a1324d3c3d35f399 Mon Sep 17 00:00:00 2001 From: "christian.lutnik" Date: Mon, 16 Mar 2026 17:19:51 +0100 Subject: [PATCH 16/16] use byte buffer Signed-off-by: christian.lutnik --- .../flagd/core/targeting/Fractional.java | 29 +++++-------------- 1 file changed, 8 insertions(+), 21 deletions(-) diff --git a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java index d7e06f3a4..10ff37b15 100644 --- a/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java +++ b/tools/flagd-core/src/main/java/dev/openfeature/contrib/tools/flagd/core/targeting/Fractional.java @@ -5,6 +5,7 @@ import io.github.jamsesso.jsonlogic.evaluator.expressions.PreEvaluatedArgumentsExpression; import java.math.BigDecimal; import java.math.BigInteger; +import java.nio.ByteBuffer; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; @@ -81,35 +82,21 @@ public Object evaluate(List arguments, Object data, String jsonPath) throws Json private byte[] numberToByteArray(Number number) { if (number instanceof Integer) { - return new byte[] { - (byte) ((int) number >> 24), - (byte) ((int) number >> 16), - (byte) ((int) number >> 8), - (byte) ((int) number) - }; + return ByteBuffer.allocate(4).putInt(number.intValue()).array(); } else if (number instanceof Double) { - return numberToByteArray(Double.doubleToLongBits((Double) number)); + return ByteBuffer.allocate(8).putDouble(number.doubleValue()).array(); } else if (number instanceof Long) { - return new byte[] { - (byte) ((long) number >> 56), - (byte) ((long) number >> 48), - (byte) ((long) number >> 40), - (byte) ((long) number >> 32), - (byte) ((long) number >> 24), - (byte) ((long) number >> 16), - (byte) ((long) number >> 8), - (byte) ((long) number) - }; + return ByteBuffer.allocate(8).putLong(number.longValue()).array(); } else if (number instanceof BigInteger) { return ((BigInteger) number).toByteArray(); } else if (number instanceof Byte) { - return new byte[] {(byte) number}; + return new byte[] {number.byteValue()}; } else if (number instanceof Short) { - return new byte[] {(byte) ((short) number >> 8), (byte) ((short) number)}; + return ByteBuffer.allocate(2).putShort(number.shortValue()).array(); } else if (number instanceof Float) { - return numberToByteArray(Float.floatToIntBits((Float) number)); + return ByteBuffer.allocate(4).putFloat(number.floatValue()).array(); } else if (number instanceof BigDecimal) { - return numberToByteArray(Double.doubleToLongBits(number.doubleValue())); + return ByteBuffer.allocate(8).putDouble(number.doubleValue()).array(); } else { throw new IllegalArgumentException("Unsupported number type: " + number.getClass()); }