Skip to content

Commit 8bd0229

Browse files
feat: precompute bandits (#180)
* precomputed flags and tests * lint and timestamp * add flag obfuscation * explicit params * tidy * use ConfigurationWireFormat * bump version after main merge * tweaks * rerig response types * salted obfuscation * polish * more tests and set unknown env name * realign types * return ConfigWire * export ConfigWire * Interfaces * lint nit * make flagKey optional * drop test export * bandits wip * add bandits with a hammer * wip * bandits and unobfuscated bandit tests * include and obfuscate more data * refactor to flattened precomputed bandit map * rename * renaming * createdAt * refactor common config interfaces * precomputed response will always be obfuscated * fixes, obfuscated only --------- Co-authored-by: Leo Romanovsky <[email protected]>
1 parent 5dc25f4 commit 8bd0229

File tree

8 files changed

+454
-134
lines changed

8 files changed

+454
-134
lines changed

src/client/eppo-client-with-bandits.spec.ts

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,11 @@ import ApiEndpoints from '../api-endpoints';
1010
import { IAssignmentEvent, IAssignmentLogger } from '../assignment-logger';
1111
import { BanditEvaluation, BanditEvaluator } from '../bandit-evaluator';
1212
import { IBanditEvent, IBanditLogger } from '../bandit-logger';
13+
import {
14+
IConfigurationWire,
15+
IPrecomputedConfiguration,
16+
IObfuscatedPrecomputedConfigurationResponse,
17+
} from '../configuration';
1318
import ConfigurationRequestor from '../configuration-requestor';
1419
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
1520
import { Evaluator, FlagEvaluation } from '../evaluator';
@@ -19,7 +24,8 @@ import {
1924
} from '../flag-evaluation-details-builder';
2025
import FetchHttpClient from '../http-client';
2126
import { BanditVariation, BanditParameters, Flag } from '../interfaces';
22-
import { Attributes, ContextAttributes } from '../types';
27+
import { attributeEncodeBase64, setSaltOverrideForTests } from '../obfuscation';
28+
import { Attributes, BanditActions, ContextAttributes } from '../types';
2329

2430
import EppoClient, { IAssignmentDetails } from './eppo-client';
2531

@@ -554,6 +560,7 @@ describe('EppoClient Bandits E2E test', () => {
554560
afterAll(() => {
555561
mockEvaluateFlag.mockClear();
556562
mockEvaluateBandit.mockClear();
563+
jest.restoreAllMocks();
557564
});
558565

559566
it('handles bandit actions appropriately', async () => {
@@ -619,4 +626,82 @@ describe('EppoClient Bandits E2E test', () => {
619626
});
620627
});
621628
});
629+
630+
describe('precomputed bandits', () => {
631+
const bob = 'bob';
632+
633+
const bobInfo: ContextAttributes = {
634+
numericAttributes: { age: 30, account_age: 10 },
635+
categoricalAttributes: { country: 'UK', gender_identity: 'male' },
636+
};
637+
638+
const bobActions: Record<string, BanditActions> = {
639+
banner_bandit_flag: {
640+
nike: {
641+
numericAttributes: { brand_affinity: -2.5 },
642+
categoricalAttributes: { loyalty_tier: 'bronze' },
643+
},
644+
adidas: {
645+
numericAttributes: { brand_affinity: -2.5 },
646+
categoricalAttributes: { loyalty_tier: 'bronze' },
647+
},
648+
reebok: {
649+
numericAttributes: { brand_affinity: -2.5 },
650+
categoricalAttributes: { loyalty_tier: 'bronze' },
651+
},
652+
},
653+
};
654+
655+
function getPrecomputedResults(
656+
client: EppoClient,
657+
subjectKey: string,
658+
subjectAttributes: ContextAttributes,
659+
banditActions: Record<string, BanditActions>,
660+
): IPrecomputedConfiguration {
661+
const precomputedResults = client.getPrecomputedConfiguration(
662+
subjectKey,
663+
subjectAttributes,
664+
banditActions,
665+
);
666+
667+
const { precomputed } = JSON.parse(precomputedResults) as IConfigurationWire;
668+
if (!precomputed) {
669+
fail('precomputed result was not parsed');
670+
}
671+
return precomputed;
672+
}
673+
674+
describe('obfuscated results', () => {
675+
beforeEach(() => {
676+
setSaltOverrideForTests(new Uint8Array([101, 112, 112, 111])); // e p p o => "ZXBwbw=="
677+
});
678+
679+
afterAll(() => {
680+
setSaltOverrideForTests(null);
681+
});
682+
683+
it('obfuscates precomputed bandits', () => {
684+
const bannerBanditFlagMd5 = '3ac89e06235484aa6f2aec8c33109a02';
685+
const brandAffinityB64 = 'YnJhbmRfYWZmaW5pdHk=';
686+
const loyaltyTierB64 = 'bG95YWx0eV90aWVy';
687+
const bronzeB64 = 'YnJvbnpl';
688+
const adidasB64 = 'YWRpZGFz';
689+
const modelB64 = 'MTIz'; // 123
690+
691+
const precomputed = getPrecomputedResults(client, bob, bobInfo, bobActions);
692+
693+
const response = JSON.parse(
694+
precomputed.response,
695+
) as IObfuscatedPrecomputedConfigurationResponse;
696+
697+
const numericAttrs = response.bandits[bannerBanditFlagMd5]['actionNumericAttributes'];
698+
const categoricalAttrs =
699+
response.bandits[bannerBanditFlagMd5]['actionCategoricalAttributes'];
700+
expect(response.bandits[bannerBanditFlagMd5]['action']).toEqual(adidasB64);
701+
expect(response.bandits[bannerBanditFlagMd5]['modelVersion']).toEqual(modelB64);
702+
expect(categoricalAttrs[loyaltyTierB64]).toEqual(bronzeB64);
703+
expect(numericAttrs[brandAffinityB64]).toEqual(attributeEncodeBase64(-2.5));
704+
});
705+
});
706+
});
622707
});

src/client/eppo-client.spec.ts

Lines changed: 28 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -13,13 +13,17 @@ import {
1313
validateTestAssignments,
1414
} from '../../test/testHelpers';
1515
import { IAssignmentLogger } from '../assignment-logger';
16-
import { IConfigurationWire } from '../configuration';
16+
import {
17+
IConfigurationWire,
18+
IObfuscatedPrecomputedConfigurationResponse,
19+
ObfuscatedPrecomputedConfigurationResponse,
20+
} from '../configuration';
1721
import { IConfigurationStore } from '../configuration-store/configuration-store';
1822
import { MemoryOnlyConfigurationStore } from '../configuration-store/memory.store';
1923
import { MAX_EVENT_QUEUE_SIZE, DEFAULT_POLL_INTERVAL_MS, POLL_JITTER_PCT } from '../constants';
2024
import { decodePrecomputedFlag } from '../decoding';
2125
import { Flag, ObfuscatedFlag, VariationType } from '../interfaces';
22-
import { setSaltOverrideForTests } from '../obfuscation';
26+
import { getMD5Hash, setSaltOverrideForTests } from '../obfuscation';
2327
import { AttributeType } from '../types';
2428

2529
import EppoClient, { FlagConfigurationRequestParameters, checkTypeMatch } from './eppo-client';
@@ -47,6 +51,8 @@ describe('EppoClient E2E test', () => {
4751
key: 'a',
4852
value: 'variation-a',
4953
};
54+
const variationAEncoded = 'dmFyaWF0aW9uLWE=';
55+
const variationBEncoded = 'dmFyaWF0aW9uLWI=';
5056

5157
const variationB = {
5258
key: 'b',
@@ -214,41 +220,49 @@ describe('EppoClient E2E test', () => {
214220
});
215221

216222
it('skips disabled flags', () => {
217-
const encodedPrecomputedWire = client.getPrecomputedAssignments('subject', {}, false);
223+
const encodedPrecomputedWire = client.getPrecomputedConfiguration('subject', {});
218224
const { precomputed } = JSON.parse(encodedPrecomputedWire) as IConfigurationWire;
219225
if (!precomputed) {
220226
fail('Precomputed data not in Configuration response');
221227
}
222-
const precomputedResponse = JSON.parse(precomputed.response);
228+
const precomputedResponse = JSON.parse(
229+
precomputed.response,
230+
) as ObfuscatedPrecomputedConfigurationResponse;
223231

224232
expect(precomputedResponse).toBeTruthy();
225233
const precomputedFlags = precomputedResponse?.flags ?? {};
226-
expect(Object.keys(precomputedFlags)).toContain('anotherFlag');
227-
expect(Object.keys(precomputedFlags)).toContain(flagKey);
228-
expect(Object.keys(precomputedFlags)).not.toContain('disabledFlag');
234+
const salt = precomputedResponse.salt;
235+
236+
expect(Object.keys(precomputedFlags)).toHaveLength(2);
237+
expect(Object.keys(precomputedFlags)).toContain(getMD5Hash('anotherFlag', salt));
238+
expect(Object.keys(precomputedFlags)).toContain(getMD5Hash(flagKey, salt));
239+
expect(Object.keys(precomputedFlags)).not.toContain(getMD5Hash('disabledFlag', salt));
229240
});
230241

231242
it('evaluates and returns assignments', () => {
232-
const encodedPrecomputedWire = client.getPrecomputedAssignments('subject', {}, false);
243+
const encodedPrecomputedWire = client.getPrecomputedConfiguration('subject', {});
233244
const { precomputed } = JSON.parse(encodedPrecomputedWire) as IConfigurationWire;
234245
if (!precomputed) {
235246
fail('Precomputed data not in Configuration response');
236247
}
237-
const precomputedResponse = JSON.parse(precomputed.response);
248+
const precomputedResponse = JSON.parse(
249+
precomputed.response,
250+
) as IObfuscatedPrecomputedConfigurationResponse;
251+
const salt = precomputedResponse.salt;
238252

239253
expect(precomputedResponse).toBeTruthy();
240254
const precomputedFlags = precomputedResponse?.flags ?? {};
241-
const firstFlag = precomputedFlags[flagKey];
242-
const secondFlag = precomputedFlags['anotherFlag'];
243-
expect(firstFlag.variationValue).toEqual('variation-a');
244-
expect(secondFlag.variationValue).toEqual('variation-b');
255+
const firstFlag = precomputedFlags[getMD5Hash(flagKey, salt)];
256+
const secondFlag = precomputedFlags[getMD5Hash('anotherFlag', salt)];
257+
expect(firstFlag.variationValue).toEqual(variationAEncoded);
258+
expect(secondFlag.variationValue).toEqual(variationBEncoded);
245259
});
246260

247261
it('obfuscates assignments', () => {
248262
// Use a known salt to produce deterministic hashes
249263
setSaltOverrideForTests(new Uint8Array([7, 53, 17, 78]));
250264

251-
const encodedPrecomputedWire = client.getPrecomputedAssignments('subject', {});
265+
const encodedPrecomputedWire = client.getPrecomputedConfiguration('subject', {}, {});
252266
const { precomputed } = JSON.parse(encodedPrecomputedWire) as IConfigurationWire;
253267
if (!precomputed) {
254268
fail('Precomputed data not in Configuration response');

0 commit comments

Comments
 (0)