Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/lint-test-sdk.yml
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ env:

on:
pull_request:
branches: [ "*" ]
branches: [ "**" ]
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

still apply if merging into branches with slashes in their name

workflow_dispatch:
workflow_call:
inputs:
Expand Down
78 changes: 78 additions & 0 deletions src/index.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
import * as util from './util/index';

import {
getBanditsConfiguration,
getFlagsConfiguration,
getInstance,
IAssignmentEvent,
Expand Down Expand Up @@ -834,4 +835,81 @@ describe('EppoClient E2E test', () => {
});
});
});

describe('getBanditsConfiguration', () => {
it('returns null when no bandits are configured', async () => {
await init({
apiKey: 'dummy',
baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`,
assignmentLogger: { logAssignment: jest.fn() },
});

// The default mock doesn't include bandits, so this should return null
const banditsConfig = getBanditsConfiguration();
expect(banditsConfig).toBeNull();
});

it('returns bandits configuration JSON matching bandit-models-v1.json structure', async () => {
await init({
apiKey: TEST_BANDIT_API_KEY,
baseUrl: `http://127.0.0.1:${TEST_SERVER_PORT}`,
assignmentLogger: { logAssignment: jest.fn() },
banditLogger: { logBanditAction: jest.fn() },
});

const banditsConfig = getBanditsConfiguration();
expect(banditsConfig).not.toBeNull();

const parsed = JSON.parse(banditsConfig ?? '');

// Verify exact number of bandits from bandit-models-v1.json
expect(Object.keys(parsed.bandits).length).toBe(3);
expect(Object.keys(parsed.bandits).sort()).toEqual([
'banner_bandit',
'car_bandit',
'cold_start_bandit',
]);

// Verify banner_bandit structure in detail
const bannerBandit = parsed.bandits['banner_bandit'];
expect(bannerBandit.banditKey).toBe('banner_bandit');
expect(bannerBandit.modelName).toBe('falcon');
expect(bannerBandit.modelVersion).toBe('123');
expect(bannerBandit.updatedAt).toBe('2023-09-13T04:52:06.462Z');

// Verify modelData
expect(bannerBandit.modelData.gamma).toBe(1.0);
expect(bannerBandit.modelData.defaultActionScore).toBe(0.0);
expect(bannerBandit.modelData.actionProbabilityFloor).toBe(0.0);

// Verify coefficients - should have nike and adidas
expect(Object.keys(bannerBandit.modelData.coefficients).sort()).toEqual(['adidas', 'nike']);

// Verify nike coefficient structure
const nikeCoeff = bannerBandit.modelData.coefficients['nike'];
expect(nikeCoeff.actionKey).toBe('nike');
expect(nikeCoeff.intercept).toBe(1.0);
expect(nikeCoeff.actionNumericCoefficients.length).toBe(1);
expect(nikeCoeff.actionNumericCoefficients[0]).toEqual({
attributeKey: 'brand_affinity',
coefficient: 1.0,
missingValueCoefficient: -0.1,
});
expect(nikeCoeff.actionCategoricalCoefficients.length).toBe(2);
expect(nikeCoeff.subjectNumericCoefficients.length).toBe(1);
expect(nikeCoeff.subjectCategoricalCoefficients.length).toBe(1);

// Verify car_bandit has different settings
const carBandit = parsed.bandits['car_bandit'];
expect(carBandit.modelVersion).toBe('456');
expect(carBandit.modelData.defaultActionScore).toBe(5.0);
expect(carBandit.modelData.actionProbabilityFloor).toBe(0.2);
expect(Object.keys(carBandit.modelData.coefficients)).toEqual(['toyota']);

// Verify cold_start_bandit has empty coefficients
const coldStartBandit = parsed.bandits['cold_start_bandit'];
expect(coldStartBandit.modelVersion).toBe('cold start');
expect(Object.keys(coldStartBandit.modelData.coefficients).length).toBe(0);
});
});
});
29 changes: 29 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,35 @@ function reconstructBanditReferences(): Record<
return banditReferences;
}

/**
* Returns the current bandits configuration as a JSON string.
* This can be used together with getFlagsConfiguration() to bootstrap
* another SDK instance using offlineInit().
*
* @returns JSON string containing the bandits configuration, or null if not initialized or no bandits
* @public
*/
export function getBanditsConfiguration(): string | null {
if (!banditModelConfigurationStore) {
return null;
}
Comment on lines +264 to +267
Copy link

Copilot AI Jan 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The early return when banditModelConfigurationStore is not initialized lacks test coverage. Add a test case that calls getBanditsConfiguration() before init() to verify it returns null.

Copilot uses AI. Check for mistakes.

const bandits = banditModelConfigurationStore.entries();

// Return null if there are no bandits
if (Object.keys(bandits).length === 0) {
return null;
}

const configuration: {
bandits: Record<string, BanditParameters>;
} = {
bandits,
};
Comment on lines +276 to +280
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is simpler than the original flag configuration because we basically store bandit configurations (model parameters) as-is


return JSON.stringify(configuration);
}

function newEventDispatcher(
sdkKey: string,
config: IClientConfig['eventTracking'] = {},
Expand Down