From 1e37d7e1f1c1875f72aee2dfabef4a999d7e30d4 Mon Sep 17 00:00:00 2001 From: Simon Schrottner Date: Fri, 11 Sep 2026 14:00:48 +0200 Subject: [PATCH] feat: flags and scenarios for numeric precision A flag value can survive a round trip through the wrong numeric type and come back subtly wrong, and nothing here noticed. 2147483647 routed through a 32-bit float returns 2147483648. 9007199254740991 through anything narrower than a double is rounded. A float that happens to be integral, 10.0, can arrive as the integer 10 and look correct until something divides by it. So three flags, and scenarios in both harnesses that would catch each case: large-integer-flag 2^31 - 1, the largest 32-bit signed integer huge-integer-flag 2^53 - 1, the largest integer a double holds exactly integral-float-flag 10.0, a float whose value is integral The flags file is picked up by the default configuration automatically, since that configuration is every file in flags/ combined. huge-integer-flag is tagged @large-integers separately from the other two, because a language whose integer type is 32 bits cannot ask for that value at all -- excluding the tag is the honest answer there, rather than a failure. The rest are tagged @precision so a provider that cannot pass them yet can exclude them and migrate, which is what the tagging scheme in the README is for. The variant names are max-int32, max-safe and ten because those are the names the OpenFeature provider conformance suite asserts (open-feature/spec#423). The same reasoning applies as for the falsy flags: this harness and that suite describe the same backend, and two vocabularies for one flag set is how they drift apart. A backend serving this configuration now satisfies that suite's precision scenarios without transcribing anything. No coercion scenarios here. Whether a provider may return a float through an integer accessor is unsettled in the specification (open-feature/spec#430) and is capability-gated where it is tested, so it does not belong in a harness every SDK runs by default. Signed-off-by: Simon Schrottner --- evaluator/flags/testkit-flags.json | 24 ++++++++++++++++++ evaluator/gherkin/precision.feature | 38 +++++++++++++++++++++++++++++ flags/precision-flags.json | 28 +++++++++++++++++++++ gherkin/evaluation.feature | 29 ++++++++++++++++++++++ 4 files changed, 119 insertions(+) create mode 100644 evaluator/gherkin/precision.feature create mode 100644 flags/precision-flags.json diff --git a/evaluator/flags/testkit-flags.json b/evaluator/flags/testkit-flags.json index cd55c18..e2e70cd 100644 --- a/evaluator/flags/testkit-flags.json +++ b/evaluator/flags/testkit-flags.json @@ -699,6 +699,30 @@ } }, "defaultVariant": "template" + }, + "large-integer-flag": { + "state": "ENABLED", + "variants": { + "one": 1, + "max-int32": 2147483647 + }, + "defaultVariant": "max-int32" + }, + "huge-integer-flag": { + "state": "ENABLED", + "variants": { + "one": 1, + "max-safe": 9007199254740991 + }, + "defaultVariant": "max-safe" + }, + "integral-float-flag": { + "state": "ENABLED", + "variants": { + "tenth": 0.1, + "ten": 10.0 + }, + "defaultVariant": "ten" } }, "$evaluators": { diff --git a/evaluator/gherkin/precision.feature b/evaluator/gherkin/precision.feature new file mode 100644 index 0000000..dce6ce5 --- /dev/null +++ b/evaluator/gherkin/precision.feature @@ -0,0 +1,38 @@ +@precision +Feature: Evaluator numeric precision + + # Validates that a numeric flag value survives evaluation unrounded and unnarrowed. + # The evaluator has no accessor types of its own, so what is under test here is narrower than + # in the provider suite: only that the value written in the flag definition is the value that + # comes back out. + # Flags are configured in evaluator/flags/testkit-flags.json. + + Background: + Given an evaluator + + Scenario Outline: Resolve numeric values without loss of precision + Given a -flag with key "" and a fallback value "" + When the flag was evaluated with details + Then the resolved details value should be "" + And the reason should be "STATIC" + + Examples: Integer evaluations + # 2147483647 is 2^31 - 1, outside what a 32-bit float represents exactly, so a round trip + # through one returns 2147483648. + | key | type | default | resolved_value | + | large-integer-flag | Integer | 1 | 2147483647 | + + Examples: Float evaluations + # A float whose value is integral must stay a float rather than arriving as 10. + | key | type | default | resolved_value | + | integral-float-flag | Float | 0.1 | 10.0 | + + @large-integers + Scenario: Resolve an integer beyond 32 bits without loss of precision + # 9007199254740991 is 2^53 - 1, the largest integer a double represents exactly. Tagged + # separately because a language whose integer type is 32 bits cannot ask for it at all -- + # excluding @large-integers is the honest answer there, not failing it. + Given a Integer-flag with key "huge-integer-flag" and a fallback value "1" + When the flag was evaluated with details + Then the resolved details value should be "9007199254740991" + And the reason should be "STATIC" diff --git a/flags/precision-flags.json b/flags/precision-flags.json new file mode 100644 index 0000000..899c4ae --- /dev/null +++ b/flags/precision-flags.json @@ -0,0 +1,28 @@ +{ + "flags": { + "large-integer-flag": { + "state": "ENABLED", + "variants": { + "one": 1, + "max-int32": 2147483647 + }, + "defaultVariant": "max-int32" + }, + "huge-integer-flag": { + "state": "ENABLED", + "variants": { + "one": 1, + "max-safe": 9007199254740991 + }, + "defaultVariant": "max-safe" + }, + "integral-float-flag": { + "state": "ENABLED", + "variants": { + "tenth": 0.1, + "ten": 10.0 + }, + "defaultVariant": "ten" + } + } +} diff --git a/gherkin/evaluation.feature b/gherkin/evaluation.feature index a6516c7..04cac47 100644 --- a/gherkin/evaluation.feature +++ b/gherkin/evaluation.feature @@ -34,6 +34,35 @@ Feature: flagd evaluations | integer-zero-flag | Integer | 1 | 0 | | float-zero-flag | Float | 0.1 | 0.0 | + @precision + Scenario Outline: Resolves numeric values without loss of precision + # 2147483647 is 2^31 - 1. It is outside the range a 32-bit float represents exactly, so a + # provider or transport that routes integers through a float and back returns 2147483648. + # 10.0 is the mirror case: a float whose value happens to be integral, which must stay a + # float rather than arriving as the integer 10. + Given a -flag with key "" and a default value "" + When the flag was evaluated with details + Then the resolved details value should be "" + And the variant should be "" + And the reason should be "STATIC" + + Examples: + | key | type | default | resolved_value | variant | + | large-integer-flag | Integer | 1 | 2147483647 | max-int32 | + | integral-float-flag | Float | 0.1 | 10.0 | ten | + + @precision @large-integers + Scenario: Resolves an integer beyond 32 bits without loss of precision + # 9007199254740991 is 2^53 - 1, the largest integer a double represents exactly. Separate + # from the scenario above, and separately tagged, because a language whose integer type is + # 32 bits cannot ask for it at all -- excluding @large-integers is the honest answer there, + # not failing it. + Given a Integer-flag with key "huge-integer-flag" and a default value "1" + When the flag was evaluated with details + Then the resolved details value should be "9007199254740991" + And the variant should be "max-safe" + And the reason should be "STATIC" + @targeting Scenario Outline: Resolves zero value with targeting Given a -flag with key "" and a default value ""