Skip to content

Commit 44e1086

Browse files
aarsilvclaude
andauthored
Offline Init Part 2 of 4: Add getBanditsConfiguration() to export bandit models as JSON (#117)
* Add getBanditsConfiguration() to export bandit models as JSON This function returns the current bandits configuration as a JSON string that can be used together with getFlagsConfiguration() to bootstrap another SDK instance. Returns null if no bandits are configured. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Move getBanditsConfiguration tests inside main describe block Fixes test isolation issue where tests ran after API server was closed. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Improve getBanditsConfiguration test with specific assertions Verify exact structure from bandit-models-v1.json including: - Exact number of bandits (3) - Specific bandit keys (banner_bandit, car_bandit, cold_start_bandit) - Detailed banner_bandit structure verification - Different settings for car_bandit and cold_start_bandit Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Fix CI workflow to run on PRs targeting branches with slashes Change branches pattern from "*" to "**" so the workflow triggers for PRs targeting branches like "aarsilv/feature-name" (stacked PRs). The single asterisk doesn't match "/" characters in GitHub Actions. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * Use typed BanditsConfigurationResponse for getBanditsConfiguration() - Add BanditsConfigurationResponse interface matching IBanditParametersResponse structure from the common package (with updatedAt optional since not stored) - Update getBanditsConfiguration() to use the typed interface This ensures consistency with getFlagsConfiguration() and provides better type safety for the export/import workflow. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com> * add comment --------- Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
1 parent e90b2e5 commit 44e1086

3 files changed

Lines changed: 113 additions & 1 deletion

File tree

.github/workflows/lint-test-sdk.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ env:
66

77
on:
88
pull_request:
9-
branches: [ "*" ]
9+
branches: [ "**" ]
1010
workflow_dispatch:
1111
workflow_call:
1212
inputs:

src/index.spec.ts

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
import * as util from './util/index';
3434

3535
import {
36+
getBanditsConfiguration,
3637
getFlagsConfiguration,
3738
getInstance,
3839
IAssignmentEvent,
@@ -834,4 +835,85 @@ describe('EppoClient E2E test', () => {
834835
});
835836
});
836837
});
838+
839+
describe('getBanditsConfiguration', () => {
840+
it('returns empty bandits configuration when no bandits are configured', async () => {
841+
await init({
842+
apiKey: 'dummy',
843+
baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`,
844+
assignmentLogger: { logAssignment: jest.fn() },
845+
});
846+
847+
// The default mock doesn't include bandits, so this should return an empty bandits map
848+
const banditsConfig = getBanditsConfiguration();
849+
expect(banditsConfig).not.toBeNull();
850+
expect(banditsConfig).toBeDefined();
851+
const parsed = JSON.parse(banditsConfig as string);
852+
expect(parsed.bandits).toEqual({});
853+
expect(parsed.updatedAt).toBeDefined();
854+
});
855+
856+
it('returns bandits configuration JSON matching bandit-models-v1.json structure', async () => {
857+
await init({
858+
apiKey: TEST_BANDIT_API_KEY,
859+
baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`,
860+
assignmentLogger: { logAssignment: jest.fn() },
861+
banditLogger: { logBanditAction: jest.fn() },
862+
});
863+
864+
const banditsConfig = getBanditsConfiguration();
865+
expect(banditsConfig).not.toBeNull();
866+
867+
const parsed = JSON.parse(banditsConfig ?? '');
868+
869+
// Verify exact number of bandits from bandit-models-v1.json
870+
expect(Object.keys(parsed.bandits).length).toBe(3);
871+
expect(Object.keys(parsed.bandits).sort()).toEqual([
872+
'banner_bandit',
873+
'car_bandit',
874+
'cold_start_bandit',
875+
]);
876+
877+
// Verify banner_bandit structure in detail
878+
const bannerBandit = parsed.bandits['banner_bandit'];
879+
expect(bannerBandit.banditKey).toBe('banner_bandit');
880+
expect(bannerBandit.modelName).toBe('falcon');
881+
expect(bannerBandit.modelVersion).toBe('123');
882+
expect(bannerBandit.updatedAt).toBe('2023-09-13T04:52:06.462Z');
883+
884+
// Verify modelData
885+
expect(bannerBandit.modelData.gamma).toBe(1.0);
886+
expect(bannerBandit.modelData.defaultActionScore).toBe(0.0);
887+
expect(bannerBandit.modelData.actionProbabilityFloor).toBe(0.0);
888+
889+
// Verify coefficients - should have nike and adidas
890+
expect(Object.keys(bannerBandit.modelData.coefficients).sort()).toEqual(['adidas', 'nike']);
891+
892+
// Verify nike coefficient structure
893+
const nikeCoeff = bannerBandit.modelData.coefficients['nike'];
894+
expect(nikeCoeff.actionKey).toBe('nike');
895+
expect(nikeCoeff.intercept).toBe(1.0);
896+
expect(nikeCoeff.actionNumericCoefficients.length).toBe(1);
897+
expect(nikeCoeff.actionNumericCoefficients[0]).toEqual({
898+
attributeKey: 'brand_affinity',
899+
coefficient: 1.0,
900+
missingValueCoefficient: -0.1,
901+
});
902+
expect(nikeCoeff.actionCategoricalCoefficients.length).toBe(2);
903+
expect(nikeCoeff.subjectNumericCoefficients.length).toBe(1);
904+
expect(nikeCoeff.subjectCategoricalCoefficients.length).toBe(1);
905+
906+
// Verify car_bandit has different settings
907+
const carBandit = parsed.bandits['car_bandit'];
908+
expect(carBandit.modelVersion).toBe('456');
909+
expect(carBandit.modelData.defaultActionScore).toBe(5.0);
910+
expect(carBandit.modelData.actionProbabilityFloor).toBe(0.2);
911+
expect(Object.keys(carBandit.modelData.coefficients)).toEqual(['toyota']);
912+
913+
// Verify cold_start_bandit has empty coefficients
914+
const coldStartBandit = parsed.bandits['cold_start_bandit'];
915+
expect(coldStartBandit.modelVersion).toBe('cold start');
916+
expect(Object.keys(coldStartBandit.modelData.coefficients).length).toBe(0);
917+
});
918+
});
837919
});

src/index.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,18 @@ interface FlagsConfigurationResponse {
7878
banditReferences: Record<string, BanditReference>;
7979
}
8080

81+
/**
82+
* Represents the bandits configuration response format.
83+
*
84+
* TODO: Remove this local definition once IBanditParametersResponse is exported from @eppo/js-client-sdk-common.
85+
* This duplicates the IBanditParametersResponse interface from the common package's http-client module,
86+
* which is not currently exported from the package's public API.
87+
*/
88+
interface BanditsConfigurationResponse {
89+
updatedAt: string;
90+
bandits: Record<string, BanditParameters>;
91+
}
92+
8193
export const NO_OP_EVENT_DISPATCHER: EventDispatcher = {
8294
// eslint-disable-next-line @typescript-eslint/no-empty-function
8395
attachContext: () => {},
@@ -246,6 +258,24 @@ function reconstructBanditReferences(): Record<string, BanditReference> {
246258
return banditReferences;
247259
}
248260

261+
/**
262+
* Returns the current bandits configuration as a JSON string.
263+
* This can be used together with getFlagsConfiguration() to bootstrap
264+
* another SDK instance using offlineInit().
265+
*
266+
* @returns JSON string containing the bandits configuration
267+
* @public
268+
*/
269+
export function getBanditsConfiguration(): string {
270+
// Build configuration matching BanditsConfigurationResponse structure.
271+
const configuration: BanditsConfigurationResponse = {
272+
updatedAt: new Date().toISOString(), // TODO: ideally we can track this and use it when regenerating bandits configuration
273+
bandits: banditModelConfigurationStore ? banditModelConfigurationStore.entries() : {},
274+
};
275+
276+
return JSON.stringify(configuration);
277+
}
278+
249279
function newEventDispatcher(
250280
sdkKey: string,
251281
config: IClientConfig['eventTracking'] = {},

0 commit comments

Comments
 (0)