From bfaf26c6d023add7375dc6696b495f33e88e9a97 Mon Sep 17 00:00:00 2001 From: lea konvalinka Date: Thu, 26 Mar 2026 11:58:14 +0100 Subject: [PATCH 1/8] feat: high resolution fractional enhancements Signed-off-by: Lea Konvalinka chore(main): release 3.3.0 (#353) Signed-off-by: OpenFeature Bot <109696520+openfeaturebot@users.noreply.github.com> Signed-off-by: Lea Konvalinka --- .../flagd/src/e2e/tests/in-process.spec.ts | 3 +- .../src/lib/targeting/fractional.ts | 33 +++++----- .../src/lib/targeting/targeting.spec.ts | 62 +++++++++++++++++-- libs/shared/flagd-core/test-harness | 2 +- 4 files changed, 79 insertions(+), 21 deletions(-) diff --git a/libs/providers/flagd/src/e2e/tests/in-process.spec.ts b/libs/providers/flagd/src/e2e/tests/in-process.spec.ts index 9154197f8..9438bac1d 100644 --- a/libs/providers/flagd/src/e2e/tests/in-process.spec.ts +++ b/libs/providers/flagd/src/e2e/tests/in-process.spec.ts @@ -24,7 +24,8 @@ describe('in-process', () => { loadFeatures(GHERKIN_FLAGD, { // remove filters as we add support for features // see: https://github.com/open-feature/js-sdk-contrib/issues/1096 and child issues - tagFilter: '@in-process and not @targetURI and not @sync and not @unixsocket and not @deprecated', + tagFilter: + '@in-process and not @targetURI and not @sync and not @unixsocket and not @deprecated and not @fractional-v1', scenarioNameTemplate: (vars) => { return `${vars.scenarioTitle} (${vars.scenarioTags.join(',')} ${vars.featureTags.join(',')})`; }, diff --git a/libs/shared/flagd-core/src/lib/targeting/fractional.ts b/libs/shared/flagd-core/src/lib/targeting/fractional.ts index 0de62d133..130aac0b8 100644 --- a/libs/shared/flagd-core/src/lib/targeting/fractional.ts +++ b/libs/shared/flagd-core/src/lib/targeting/fractional.ts @@ -48,17 +48,27 @@ export function fractional(data: unknown, context: EvaluationContextWithLogger): return null; } - // hash in signed 32 format. Bitwise operation here works in signed 32 hence the conversion - const hash = new MurmurHash3(bucketBy).result() | 0; - const bucket = (Math.abs(hash) / 2147483648) * 100; + // Validate total weight does not exceed Math.MaxInt32 (2,147,483,647) + const MAX_WEIGHT = 2147483647; + if (bucketingList.totalWeight > MAX_WEIGHT) { + logger.debug( + `Invalid ${fractionalRule} configuration: sum of weights exceeds Math.MaxInt32 (${MAX_WEIGHT}), got ${bucketingList.totalWeight}`, + ); + return null; + } + + // hash in unsigned 32-bit format. The MurmurHash3 result is treated as uint32. + // Use BigInt for the multiplication to avoid 53-bit float precision limits. + const hashUint32 = BigInt(new MurmurHash3(bucketBy).result() >>> 0); + const bucket = (hashUint32 * BigInt(bucketingList.totalWeight)) >> BigInt(32); - let sum = 0; + let sum = BigInt(0); for (let i = 0; i < bucketingList.fractions.length; i++) { const bucketEntry = bucketingList.fractions[i]; - sum += relativeWeight(bucketingList.totalWeight, bucketEntry.fraction); + sum += BigInt(bucketEntry.fraction); - if (sum >= bucket) { + if (sum > bucket) { return bucketEntry.variant; } } @@ -66,13 +76,6 @@ export function fractional(data: unknown, context: EvaluationContextWithLogger): return null; } -function relativeWeight(totalWeight: number, weight: number): number { - if (weight == 0) { - return 0; - } - return (weight * 100) / totalWeight; -} - function toBucketingList(from: unknown[]): { fractions: { variant: string; fraction: number }[]; totalWeight: number; @@ -97,8 +100,8 @@ function toBucketingList(from: unknown[]): { let weight = 1; if (entry.length >= 2) { - if (typeof entry[1] !== 'number') { - throw new Error('Bucketing require bucketing percentage to be present'); + if (typeof entry[1] !== 'number' || !Number.isInteger(entry[1])) { + throw new Error('Bucketing require bucketing weight to be an integer'); } weight = entry[1]; } diff --git a/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts b/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts index ca7aac39b..ced8736ed 100644 --- a/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts +++ b/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts @@ -173,13 +173,13 @@ describe('targeting', () => { expect(targeting.evaluate('flagA', { key: 'bucketKeyA' })).toBe('red'); }); - it('should evaluate to blue with key "bucketKeyB"', () => { + it('should evaluate to blue with key "bucketKey4"', () => { const logic = { fractional: [{ cat: [{ var: '$flagd.flagKey' }, { var: 'key' }] }, ['red', 50], ['blue', 50]], }; const targeting = new Targeting(logic, logger); - expect(targeting.evaluate('flagA', { key: 'bucketKeyB' })).toBe('blue'); + expect(targeting.evaluate('flagA', { key: 'bucketKey4' })).toBe('blue'); }); it('should evaluate valid rule with targeting key', () => { @@ -191,7 +191,7 @@ describe('targeting', () => { }; const targeting = new Targeting(logic, logger); - expect(targeting.evaluate('flagA', { targetingKey: 'bucketKeyB' })).toBe('blue'); + expect(targeting.evaluate('flagA', { targetingKey: 'bucketKeyB' })).toBe('red'); }); it('should evaluate valid rule with targeting key although one does not have a fraction', () => { @@ -200,7 +200,7 @@ describe('targeting', () => { }; const targeting = new Targeting(logic, logger); - expect(targeting.evaluate('flagA', { targetingKey: 'bucketKeyB' })).toBe('blue'); + expect(targeting.evaluate('flagA', { targetingKey: 'bucketKeyB' })).toBe('red'); }); it('should return null if targeting key is missing', () => { @@ -264,6 +264,60 @@ describe('targeting', () => { expect(logger.debug).toHaveBeenCalled(); }); + it('should not support float (non-integer) weights', () => { + const logic = { + fractional: [ + ['red', 0.5], + ['blue', 99.5], + ], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', { targetingKey: 'key' })).toBe(null); + expect(logger.debug).toHaveBeenCalled(); + }); + + it('should return null when total weight exceeds Math.MaxInt32 (2147483647)', () => { + const logic = { + fractional: [ + ['red', 2000000000], + ['blue', 200000000], // total = 2200000000 > 2147483647 + ], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', { targetingKey: 'key' })).toBe(null); + expect(logger.debug).toHaveBeenCalled(); + }); + + it('should support total weight exactly equal to Math.MaxInt32 (2147483647)', () => { + const logic = { + fractional: [ + ['a', 2147483646], + ['b', 1], // total = 2147483647 = MaxInt32, valid + ], + }; + const targeting = new Targeting(logic, logger); + + // bucketBy = 'flagA' + 'testKey' = 'flagAtestKey' -> 'a' (nearly all weight on 'a') + const result = targeting.evaluate('flagA', { targetingKey: 'testKey' }); + expect(result).toBe('a'); + }); + + it('should support sub-percent granularity with large integer weights (0.1% red)', () => { + const logic = { + fractional: ['user2077', ['red', 10], ['blue', 9990]], + }; + const targeting = new Targeting(logic, logger); + expect(targeting.evaluate('flagA', {})).toBe('red'); + + const logicControl = { + fractional: ['user0', ['red', 10], ['blue', 9990]], + }; + const targetingControl = new Targeting(logicControl, logger); + expect(targetingControl.evaluate('flagA', {})).toBe('blue'); + }); + it('should log using a custom logger', () => { const logic = { fractional: [ diff --git a/libs/shared/flagd-core/test-harness b/libs/shared/flagd-core/test-harness index 3bff4b7ea..dc43f1cbb 160000 --- a/libs/shared/flagd-core/test-harness +++ b/libs/shared/flagd-core/test-harness @@ -1 +1 @@ -Subproject commit 3bff4b7eaee0efc8cfe60e0ef6fbd77441b370e6 +Subproject commit dc43f1cbb714968054a79c3b99658927c9a813ae From c0de2fe700cbc8f2022c210822768dcdc1420201 Mon Sep 17 00:00:00 2001 From: lea konvalinka Date: Thu, 26 Mar 2026 14:20:46 +0100 Subject: [PATCH 2/8] feat: nested fractional enhancements Signed-off-by: Lea Konvalinka --- .../providers/flagd/src/e2e/tests/rpc.spec.ts | 3 +- .../src/lib/targeting/fractional.ts | 33 ++-- .../src/lib/targeting/targeting.spec.ts | 184 +++++++++++++++++- 3 files changed, 201 insertions(+), 19 deletions(-) diff --git a/libs/providers/flagd/src/e2e/tests/rpc.spec.ts b/libs/providers/flagd/src/e2e/tests/rpc.spec.ts index 38529babe..efce105b2 100644 --- a/libs/providers/flagd/src/e2e/tests/rpc.spec.ts +++ b/libs/providers/flagd/src/e2e/tests/rpc.spec.ts @@ -25,7 +25,8 @@ describe('rpc', () => { tagFilter: // remove filters as we add support for features // see: https://github.com/open-feature/js-sdk-contrib/issues/1096 and child issues - '@rpc and not @targetURI and not @caching and not @unixsocket and not @deprecated', + // TODO remove last two tags from being excluded once flagd supports the fractional enhancements + '@rpc and not @targetURI and not @caching and not @unixsocket and not @deprecated and not @fractional-v1 and not @fractional-v2 and not @fractional-nested', scenarioNameTemplate: (vars) => { return `${vars.scenarioTitle} (${vars.scenarioTags.join(',')} ${vars.featureTags.join(',')})`; }, diff --git a/libs/shared/flagd-core/src/lib/targeting/fractional.ts b/libs/shared/flagd-core/src/lib/targeting/fractional.ts index 130aac0b8..39853b3fc 100644 --- a/libs/shared/flagd-core/src/lib/targeting/fractional.ts +++ b/libs/shared/flagd-core/src/lib/targeting/fractional.ts @@ -5,15 +5,17 @@ import type { EvaluationContextWithLogger } from './common'; export const fractionalRule = 'fractional'; -export function fractional(data: unknown, context: EvaluationContextWithLogger): string | null { +type VariantValue = string | number | boolean | null; + +export function fractional(data: unknown, context: EvaluationContextWithLogger): VariantValue { const logger = getLoggerFromContext(context); if (!Array.isArray(data)) { return null; } const args = Array.from(data); - if (args.length < 2) { - logger.debug(`Invalid ${fractionalRule} configuration: Expected at least 2 buckets, got ${args.length}`); + if (args.length < 1) { + logger.debug(`Invalid ${fractionalRule} configuration: Expected at least 1 bucket, got ${args.length}`); return null; } @@ -77,11 +79,10 @@ export function fractional(data: unknown, context: EvaluationContextWithLogger): } function toBucketingList(from: unknown[]): { - fractions: { variant: string; fraction: number }[]; + fractions: { variant: VariantValue; fraction: number }[]; totalWeight: number; } { - // extract bucketing options - const bucketingArray: { variant: string; fraction: number }[] = []; + const bucketingArray: { variant: VariantValue; fraction: number }[] = []; let totalWeight = 0; for (let i = 0; i < from.length; i++) { @@ -94,19 +95,27 @@ function toBucketingList(from: unknown[]): { throw new Error('Invalid bucketing entry. Requires at least a variant'); } - if (typeof entry[0] !== 'string') { - throw new Error('Bucketing require variant to be present in string format'); + let variant: VariantValue; + if (typeof entry[0] === 'string' || typeof entry[0] === 'number' || typeof entry[0] === 'boolean') { + variant = entry[0]; + } else if (entry[0] === null || entry[0] === undefined) { + variant = null; + } else { + throw new Error( + 'Bucketing requires variant to be a string, number, or boolean (or a JSONLogic expression that evaluates to one)', + ); } let weight = 1; if (entry.length >= 2) { - if (typeof entry[1] !== 'number' || !Number.isInteger(entry[1])) { - throw new Error('Bucketing require bucketing weight to be an integer'); + const raw = entry[1]; + if (typeof raw !== 'number' || !Number.isFinite(raw) || !Number.isInteger(raw)) { + throw new Error('Bucketing requires weight to be an integer'); } - weight = entry[1]; + weight = Math.max(0, raw); } - bucketingArray.push({ fraction: weight, variant: entry[0] }); + bucketingArray.push({ fraction: weight, variant }); totalWeight += weight; } diff --git a/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts b/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts index ced8736ed..b85b9940a 100644 --- a/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts +++ b/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts @@ -239,16 +239,33 @@ describe('targeting', () => { expect(targeting.evaluate('flagA', { targetingKey: 'key' })).toBe('blue'); }); - it('should not support non-string variant names', () => { + it('should support number variant names', () => { const logic = { - fractional: [ - ['red', 50], - [100, 50], - ], + fractional: [{ cat: [{ var: '$flagd.flagKey' }, { var: 'key' }] }, ['red', 50], [100, 50]], }; const targeting = new Targeting(logic, logger); - expect(targeting.evaluate('flagA', { targetingKey: 'key' })).toBe(null); + expect(targeting.evaluate('flagA', { key: 'bucketKeyA' })).toBe('red'); + expect(targeting.evaluate('flagA', { key: 'bucketKey4' })).toBe(100); + }); + + it('should support boolean variant names', () => { + const logic = { + fractional: [{ cat: [{ var: '$flagd.flagKey' }, { var: 'key' }] }, [true, 50], [false, 50]], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', { key: 'bucketKeyA' })).toBe(true); + expect(targeting.evaluate('flagA', { key: 'bucketKey4' })).toBe(false); + }); + + it('should return null when variant expression evaluates to a non-scalar (object/array)', () => { + const logic = { + fractional: [{ var: 'targetingKey' }, [{ var: 'missingKey' }, 50], ['blue', 50]], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flag', { targetingKey: 'jon@company.com' })).toBe(null); }); it('should not support invalid bucket configurations', () => { @@ -318,6 +335,73 @@ describe('targeting', () => { expect(targetingControl.evaluate('flagA', {})).toBe('blue'); }); + it('should support a nested "if" expression as a variant name', () => { + const logic = { + fractional: [ + { var: 'targetingKey' }, + [{ if: [{ '==': [{ var: 'tier' }, 'premium'] }, 'premium', 'standard'] }, 50], + ['standard', 50], + ], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flag', { targetingKey: 'jon@company.com', tier: 'premium' })).toBe('premium'); + expect(targeting.evaluate('flag', { targetingKey: 'jon@company.com', tier: 'basic' })).toBe('standard'); + // user1 → bv(100)=76 → bucket1 → always "standard" + expect(targeting.evaluate('flag', { targetingKey: 'user1', tier: 'premium' })).toBe('standard'); + }); + + it('should support a nested "var" expression as a variant name', () => { + const logic = { + fractional: [{ var: 'targetingKey' }, [{ var: 'color' }, 50], ['blue', 50]], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flag', { targetingKey: 'jon@company.com', color: 'red' })).toBe('red'); + expect(targeting.evaluate('flag', { targetingKey: 'jon@company.com', color: 'green' })).toBe('green'); + // user1 → bv(100)=76 → bucket1 → always "blue" + expect(targeting.evaluate('flag', { targetingKey: 'user1', color: 'red' })).toBe('blue'); + }); + + it('should return null when a nested variant expression evaluates to a non-string', () => { + const logic = { + fractional: [{ var: 'targetingKey' }, [{ var: 'color' }, 50], ['blue', 50]], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flag', { targetingKey: 'jon@company.com' })).toBe(null); + }); + + it('should support a computed weight via a "var" expression', () => { + const logic = { + fractional: [ + ['new-feature', { var: 'rolloutPercent' }], + ['control', { '-': [100, { var: 'rolloutPercent' }] }], + ], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', { targetingKey: 'bucketKeyA', rolloutPercent: 10 })).toBe('new-feature'); + expect(targeting.evaluate('flagA', { targetingKey: 'bucketKeyB', rolloutPercent: 10 })).toBe('control'); + expect(targeting.evaluate('flagA', { targetingKey: 'bucketKeyB', rolloutPercent: 90 })).toBe('new-feature'); + }); + + it('should support weight=0 (variant effectively excluded from traffic)', () => { + const logic = { + fractional: [ + { var: 'targetingKey' }, + ['red', { if: [{ '==': [{ var: 'tier' }, 'premium'] }, 100, 0] }], + ['blue', 10], + ], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flag', { targetingKey: 'jon@company.com', tier: 'premium' })).toBe('red'); + expect(targeting.evaluate('flag', { targetingKey: 'jon@company.com', tier: 'basic' })).toBe('blue'); + expect(targeting.evaluate('flag', { targetingKey: 'user1', tier: 'premium' })).toBe('red'); + expect(targeting.evaluate('flag', { targetingKey: 'user1', tier: 'basic' })).toBe('blue'); + }); + it('should log using a custom logger', () => { const logic = { fractional: [ @@ -331,5 +415,93 @@ describe('targeting', () => { expect(logger.debug).not.toHaveBeenCalled(); expect(requestLogger.debug).toHaveBeenCalled(); }); + + it('should support a single-bucket list (100% traffic to one variant)', () => { + const logic = { + fractional: [['single-entry', 1]], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flagA', { targetingKey: 'anyKey' })).toBe('single-entry'); + }); + + it('should support mixed variant types and a nested fractional as a variant', () => { + const logic = { + fractional: [ + { var: 'targetingKey' }, + ['clubs', 1], + [true, 1], + [1, 1], + [ + { + fractional: [ + ['clubs', 25], + ['diamonds', 25], + ['hearts', 25], + ['spades', 25], + ], + }, + 1, + ], + ], + }; + const targeting = new Targeting(logic, logger); + const result = targeting.evaluate('flagA', { + targetingKey: 'user1', + targetingKey2: 'user2', + }); + // user1 lands in one of the four buckets — all are valid + expect(['clubs', true, 1, 'diamonds', 'hearts', 'spades']).toContain(result); + }); + + it('should support a timestamp-based weight with an explicit bucket key', () => { + const ts = Math.floor(Date.now() / 1000); + const w1 = ts - 1740000000; // large positive, grows over time + const logic = { + fractional: [ + { cat: [{ var: '$flagd.flagKey' }, { var: 'email' }] }, + ['on', { '-': [{ var: '$flagd.timestamp' }, 1740000000] }], + ['off', 100], + ], + }; + const targeting = new Targeting(logic, logger); + const result = targeting.evaluate('flag', { email: 'user@example.com' }); + + // 'on' has overwhelmingly more weight than 'off' — nearly all users get 'on' + // We just assert a valid variant is returned; the exact result depends on the hash. + expect(['on', 'off']).toContain(result); + // Sanity-check that the computed weight is positive and within bounds + expect(w1).toBeGreaterThan(0); + expect(w1 + 100).toBeLessThan(2147483647); + }); + + it('should support two timestamp-derived weights summing to a fixed total', () => { + // w1 = ts - 1740000000 (ramp up), w2 = 1800000000 - ts (ramp down) + // Their sum is always 60000000 regardless of current time. + // When ts > 1800000000 w2 goes negative and is clamped to 0 → 'off' gets no traffic. + const logic = { + fractional: [ + ['on', { '-': [{ var: '$flagd.timestamp' }, 1740000000] }], + ['off', { '-': [1800000000, { var: '$flagd.timestamp' }] }], + ], + }; + const targeting = new Targeting(logic, logger); + const result = targeting.evaluate('flagA', { targetingKey: 'user1' }); + + expect(['on', 'off']).toContain(result); + }); + + it('should clamp negative computed weights to 0 without error', () => { + const logic = { + fractional: [ + 'anyUser', + ['on', -50], // negative: clamped to 0 + ['off', 100], + ], + }; + const targeting = new Targeting(logic, logger); + + expect(targeting.evaluate('flag', {})).toBe('off'); + }); }); }); From d2a64d56ee13c1d87004b45b7566d837b8b92d56 Mon Sep 17 00:00:00 2001 From: lea konvalinka Date: Thu, 26 Mar 2026 14:54:16 +0100 Subject: [PATCH 3/8] cleanup Signed-off-by: Lea Konvalinka --- libs/shared/flagd-core/src/lib/targeting/fractional.ts | 5 +---- .../flagd-core/src/lib/targeting/targeting.spec.ts | 9 ++++----- 2 files changed, 5 insertions(+), 9 deletions(-) diff --git a/libs/shared/flagd-core/src/lib/targeting/fractional.ts b/libs/shared/flagd-core/src/lib/targeting/fractional.ts index 39853b3fc..699f1ea65 100644 --- a/libs/shared/flagd-core/src/lib/targeting/fractional.ts +++ b/libs/shared/flagd-core/src/lib/targeting/fractional.ts @@ -50,7 +50,6 @@ export function fractional(data: unknown, context: EvaluationContextWithLogger): return null; } - // Validate total weight does not exceed Math.MaxInt32 (2,147,483,647) const MAX_WEIGHT = 2147483647; if (bucketingList.totalWeight > MAX_WEIGHT) { logger.debug( @@ -59,8 +58,6 @@ export function fractional(data: unknown, context: EvaluationContextWithLogger): return null; } - // hash in unsigned 32-bit format. The MurmurHash3 result is treated as uint32. - // Use BigInt for the multiplication to avoid 53-bit float precision limits. const hashUint32 = BigInt(new MurmurHash3(bucketBy).result() >>> 0); const bucket = (hashUint32 * BigInt(bucketingList.totalWeight)) >> BigInt(32); @@ -109,7 +106,7 @@ function toBucketingList(from: unknown[]): { let weight = 1; if (entry.length >= 2) { const raw = entry[1]; - if (typeof raw !== 'number' || !Number.isFinite(raw) || !Number.isInteger(raw)) { + if (typeof raw !== 'number' || !Number.isInteger(raw)) { throw new Error('Bucketing requires weight to be an integer'); } weight = Math.max(0, raw); diff --git a/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts b/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts index b85b9940a..ea235f586 100644 --- a/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts +++ b/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts @@ -298,7 +298,7 @@ describe('targeting', () => { const logic = { fractional: [ ['red', 2000000000], - ['blue', 200000000], // total = 2200000000 > 2147483647 + ['blue', 200000000], ], }; const targeting = new Targeting(logic, logger); @@ -311,12 +311,11 @@ describe('targeting', () => { const logic = { fractional: [ ['a', 2147483646], - ['b', 1], // total = 2147483647 = MaxInt32, valid + ['b', 1], ], }; const targeting = new Targeting(logic, logger); - // bucketBy = 'flagA' + 'testKey' = 'flagAtestKey' -> 'a' (nearly all weight on 'a') const result = targeting.evaluate('flagA', { targetingKey: 'testKey' }); expect(result).toBe('a'); }); @@ -450,8 +449,8 @@ describe('targeting', () => { targetingKey: 'user1', targetingKey2: 'user2', }); - // user1 lands in one of the four buckets — all are valid - expect(['clubs', true, 1, 'diamonds', 'hearts', 'spades']).toContain(result); + // user1 → outer bucket 3 (the nested fractional slot) → nested bucketBy='flagAuser1' → 'diamonds' + expect(result).toBe('diamonds'); }); it('should support a timestamp-based weight with an explicit bucket key', () => { From 6263d673afe33bffc2ae99396c2b291bbcd193cf Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 30 Mar 2026 10:48:02 -0400 Subject: [PATCH 4/8] fixup: testharness ++ Signed-off-by: Todd Baert --- libs/shared/flagd-core/test-harness | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/libs/shared/flagd-core/test-harness b/libs/shared/flagd-core/test-harness index dc43f1cbb..2684a3ecf 160000 --- a/libs/shared/flagd-core/test-harness +++ b/libs/shared/flagd-core/test-harness @@ -1 +1 @@ -Subproject commit dc43f1cbb714968054a79c3b99658927c9a813ae +Subproject commit 2684a3ecf061002221a8be7d09e9c8f915c7b193 From 18f14139403aa878853f9182afb3f8a550243820 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Mon, 30 Mar 2026 15:01:39 -0400 Subject: [PATCH 5/8] fixup: update flagd-schemas to json-schema-v0.2.14 Signed-off-by: Todd Baert --- libs/providers/flagd-web/schemas | 2 +- libs/providers/flagd/schemas | 2 +- libs/shared/flagd-core/flagd-schemas | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/providers/flagd-web/schemas b/libs/providers/flagd-web/schemas index 9f823b5b3..a375f5ad6 160000 --- a/libs/providers/flagd-web/schemas +++ b/libs/providers/flagd-web/schemas @@ -1 +1 @@ -Subproject commit 9f823b5b36bf219f8ea342de006fdf5013c1bc79 +Subproject commit a375f5ad6a552eb8cdc54add2a20dfef4389bc7e diff --git a/libs/providers/flagd/schemas b/libs/providers/flagd/schemas index 9f823b5b3..a375f5ad6 160000 --- a/libs/providers/flagd/schemas +++ b/libs/providers/flagd/schemas @@ -1 +1 @@ -Subproject commit 9f823b5b36bf219f8ea342de006fdf5013c1bc79 +Subproject commit a375f5ad6a552eb8cdc54add2a20dfef4389bc7e diff --git a/libs/shared/flagd-core/flagd-schemas b/libs/shared/flagd-core/flagd-schemas index 9f823b5b3..a375f5ad6 160000 --- a/libs/shared/flagd-core/flagd-schemas +++ b/libs/shared/flagd-core/flagd-schemas @@ -1 +1 @@ -Subproject commit 9f823b5b36bf219f8ea342de006fdf5013c1bc79 +Subproject commit a375f5ad6a552eb8cdc54add2a20dfef4389bc7e From 9e841367a994a2d2a00a953fe411c887e8b210b7 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 31 Mar 2026 08:19:29 -0400 Subject: [PATCH 6/8] fixup: clamping impl consistency Signed-off-by: Todd Baert --- libs/shared/flagd-core/src/lib/targeting/fractional.ts | 1 + libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/libs/shared/flagd-core/src/lib/targeting/fractional.ts b/libs/shared/flagd-core/src/lib/targeting/fractional.ts index 699f1ea65..8eb5482b8 100644 --- a/libs/shared/flagd-core/src/lib/targeting/fractional.ts +++ b/libs/shared/flagd-core/src/lib/targeting/fractional.ts @@ -109,6 +109,7 @@ function toBucketingList(from: unknown[]): { if (typeof raw !== 'number' || !Number.isInteger(raw)) { throw new Error('Bucketing requires weight to be an integer'); } + // negative weights can be the result of rollout calculations, so we clamp to 0 rather than throwing an error weight = Math.max(0, raw); } diff --git a/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts b/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts index ea235f586..dae0373d1 100644 --- a/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts +++ b/libs/shared/flagd-core/src/lib/targeting/targeting.spec.ts @@ -494,8 +494,8 @@ describe('targeting', () => { const logic = { fractional: [ 'anyUser', - ['on', -50], // negative: clamped to 0 - ['off', 100], + ['on', -1000], // negative: clamped to 0 + ['off', 1], ], }; const targeting = new Targeting(logic, logger); From 955fb99386920f3a27515aa1bbd1c426cf8f94a6 Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Tue, 31 Mar 2026 10:56:22 -0400 Subject: [PATCH 7/8] fixup: update flagd-schemas to json-schema-v0.2.15 Signed-off-by: Todd Baert --- libs/providers/flagd-web/schemas | 2 +- libs/providers/flagd/schemas | 2 +- libs/shared/flagd-core/flagd-schemas | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/libs/providers/flagd-web/schemas b/libs/providers/flagd-web/schemas index a375f5ad6..1daf5ff56 160000 --- a/libs/providers/flagd-web/schemas +++ b/libs/providers/flagd-web/schemas @@ -1 +1 @@ -Subproject commit a375f5ad6a552eb8cdc54add2a20dfef4389bc7e +Subproject commit 1daf5ff56b48d582187d59e35d48c6e191c23839 diff --git a/libs/providers/flagd/schemas b/libs/providers/flagd/schemas index a375f5ad6..1daf5ff56 160000 --- a/libs/providers/flagd/schemas +++ b/libs/providers/flagd/schemas @@ -1 +1 @@ -Subproject commit a375f5ad6a552eb8cdc54add2a20dfef4389bc7e +Subproject commit 1daf5ff56b48d582187d59e35d48c6e191c23839 diff --git a/libs/shared/flagd-core/flagd-schemas b/libs/shared/flagd-core/flagd-schemas index a375f5ad6..1daf5ff56 160000 --- a/libs/shared/flagd-core/flagd-schemas +++ b/libs/shared/flagd-core/flagd-schemas @@ -1 +1 @@ -Subproject commit a375f5ad6a552eb8cdc54add2a20dfef4389bc7e +Subproject commit 1daf5ff56b48d582187d59e35d48c6e191c23839 From a686700d993576c30df8d0df8a4af76f86ad617a Mon Sep 17 00:00:00 2001 From: Todd Baert Date: Wed, 1 Apr 2026 15:35:22 -0400 Subject: [PATCH 8/8] fixup: update testbed and exclusions Signed-off-by: Todd Baert --- libs/providers/flagd/src/e2e/tests/in-process.spec.ts | 2 +- libs/providers/flagd/src/e2e/tests/rpc.spec.ts | 3 +-- libs/shared/flagd-core/test-harness | 2 +- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/libs/providers/flagd/src/e2e/tests/in-process.spec.ts b/libs/providers/flagd/src/e2e/tests/in-process.spec.ts index 9438bac1d..c5b864744 100644 --- a/libs/providers/flagd/src/e2e/tests/in-process.spec.ts +++ b/libs/providers/flagd/src/e2e/tests/in-process.spec.ts @@ -25,7 +25,7 @@ describe('in-process', () => { // remove filters as we add support for features // see: https://github.com/open-feature/js-sdk-contrib/issues/1096 and child issues tagFilter: - '@in-process and not @targetURI and not @sync and not @unixsocket and not @deprecated and not @fractional-v1', + '@in-process and not @targetURI and not @sync and not @unixsocket and not @deprecated and not @fractional-v1 and not @operator-errors', scenarioNameTemplate: (vars) => { return `${vars.scenarioTitle} (${vars.scenarioTags.join(',')} ${vars.featureTags.join(',')})`; }, diff --git a/libs/providers/flagd/src/e2e/tests/rpc.spec.ts b/libs/providers/flagd/src/e2e/tests/rpc.spec.ts index efce105b2..c20cf12fa 100644 --- a/libs/providers/flagd/src/e2e/tests/rpc.spec.ts +++ b/libs/providers/flagd/src/e2e/tests/rpc.spec.ts @@ -25,8 +25,7 @@ describe('rpc', () => { tagFilter: // remove filters as we add support for features // see: https://github.com/open-feature/js-sdk-contrib/issues/1096 and child issues - // TODO remove last two tags from being excluded once flagd supports the fractional enhancements - '@rpc and not @targetURI and not @caching and not @unixsocket and not @deprecated and not @fractional-v1 and not @fractional-v2 and not @fractional-nested', + '@rpc and not @targetURI and not @caching and not @unixsocket and not @deprecated and not @fractional-v1 and not @operator-errors', scenarioNameTemplate: (vars) => { return `${vars.scenarioTitle} (${vars.scenarioTags.join(',')} ${vars.featureTags.join(',')})`; }, diff --git a/libs/shared/flagd-core/test-harness b/libs/shared/flagd-core/test-harness index 2684a3ecf..ff2fbe6c6 160000 --- a/libs/shared/flagd-core/test-harness +++ b/libs/shared/flagd-core/test-harness @@ -1 +1 @@ -Subproject commit 2684a3ecf061002221a8be7d09e9c8f915c7b193 +Subproject commit ff2fbe6c6584953cb2753ae9188d1cee14f7f57f