Skip to content
Merged
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
82 changes: 82 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,85 @@ describe('EppoClient E2E test', () => {
});
});
});

describe('getBanditsConfiguration', () => {
it('returns empty bandits configuration 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 an empty bandits map
const banditsConfig = getBanditsConfiguration();
expect(banditsConfig).not.toBeNull();
expect(banditsConfig).toBeDefined();
const parsed = JSON.parse(banditsConfig as string);
expect(parsed.bandits).toEqual({});
expect(parsed.updatedAt).toBeDefined();
});

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);
});
});
});
30 changes: 30 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,18 @@ interface FlagsConfigurationResponse {
banditReferences: Record<string, BanditReference>;
}

/**
* Represents the bandits configuration response format.
*
* TODO: Remove this local definition once IBanditParametersResponse is exported from @eppo/js-client-sdk-common.
* This duplicates the IBanditParametersResponse interface from the common package's http-client module,
* which is not currently exported from the package's public API.
*/
interface BanditsConfigurationResponse {
updatedAt: string;
bandits: Record<string, BanditParameters>;
}

export const NO_OP_EVENT_DISPATCHER: EventDispatcher = {
// eslint-disable-next-line @typescript-eslint/no-empty-function
attachContext: () => {},
Expand Down Expand Up @@ -246,6 +258,24 @@ function reconstructBanditReferences(): Record<string, BanditReference> {
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
* @public
*/
export function getBanditsConfiguration(): string {
// Build configuration matching BanditsConfigurationResponse structure.
const configuration: BanditsConfigurationResponse = {
updatedAt: new Date().toISOString(), // TODO: ideally we can track this and use it when regenerating bandits configuration
bandits: banditModelConfigurationStore ? banditModelConfigurationStore.entries() : {},
};

return JSON.stringify(configuration);
}

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