383 lines
15 KiB
TypeScript
383 lines
15 KiB
TypeScript
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
import { StrategySelector } from '../src/services/strategy-selector.js';
|
|
import { MessagesRepository } from '../src/database/repositories.js';
|
|
import type { StrategyStats } from '../src/database/repositories.js';
|
|
import { createMockLogger, getLogsByLevel } from '../tests/mockLogger.js';
|
|
import { getTestConfig } from '../tests/testUtils.js';
|
|
|
|
function createMockMessagesRepo(stats: StrategyStats[] = []): MessagesRepository {
|
|
return {
|
|
getStrategyStats: vi.fn(() => stats),
|
|
getSubredditStats: vi.fn(() => []),
|
|
getResponseRateTrend: vi.fn(() => []),
|
|
insert: vi.fn(() => 1),
|
|
getByPostId: vi.fn(),
|
|
getRecent: vi.fn(),
|
|
getUncheckedMessages: vi.fn(),
|
|
markGotResponse: vi.fn(),
|
|
markChecked: vi.fn(),
|
|
hasSentTo: vi.fn(),
|
|
} as unknown as MessagesRepository;
|
|
}
|
|
|
|
describe('StrategySelector', () => {
|
|
let selector: StrategySelector;
|
|
let logger: ReturnType<typeof createMockLogger>;
|
|
|
|
beforeEach(() => {
|
|
logger = createMockLogger();
|
|
});
|
|
|
|
it('returns null when no variants configured', () => {
|
|
const config = getTestConfig({ strategies: { variants: [] } });
|
|
const repo = createMockMessagesRepo();
|
|
selector = new StrategySelector(config, repo, logger);
|
|
const result = selector.selectStrategy();
|
|
expect(result).toBeNull();
|
|
});
|
|
|
|
it('hasStrategies returns false when no variants', () => {
|
|
const config = getTestConfig({ strategies: { variants: [] } });
|
|
const repo = createMockMessagesRepo();
|
|
selector = new StrategySelector(config, repo, logger);
|
|
expect(selector.hasStrategies()).toBe(false);
|
|
});
|
|
|
|
it('hasStrategies returns true when variants exist', () => {
|
|
const config = getTestConfig({ strategies: { variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'test' },
|
|
] } });
|
|
const repo = createMockMessagesRepo();
|
|
selector = new StrategySelector(config, repo, logger);
|
|
expect(selector.hasStrategies()).toBe(true);
|
|
});
|
|
|
|
it('preserves pending-only sample counts for configured strategies', () => {
|
|
const config = getTestConfig({ strategies: { variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'test' },
|
|
] } });
|
|
const repo = createMockMessagesRepo([{
|
|
strategyId: 'v1', totalSent: 0, totalResponses: 0, responseRate: 0,
|
|
totalImages: 0, imageRate: 0, pendingSent: 7,
|
|
responseRateLow: 0, responseRateHigh: 100,
|
|
}]);
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
expect(selector.getStats()).toEqual([expect.objectContaining({
|
|
strategyId: 'v1', totalSent: 0, pendingSent: 7, configured: true,
|
|
})]);
|
|
});
|
|
|
|
describe('explore mode', () => {
|
|
it('selects random variant when exploration rate is 1.0', () => {
|
|
const config = getTestConfig({ strategies: { explorationRate: 1.0, variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'prompt1' },
|
|
{ id: 'v2', weight: 1, promptOverride: 'prompt2' },
|
|
] } });
|
|
const repo = createMockMessagesRepo();
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
const result = selector.selectStrategy();
|
|
expect(result).not.toBeNull();
|
|
expect(['v1', 'v2']).toContain(result!.id);
|
|
expect(result!.reason).toBe('explore');
|
|
});
|
|
|
|
it('selects random variant when exploration rate is 0.5', () => {
|
|
const config = getTestConfig({ strategies: { explorationRate: 0.5, variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'prompt1' },
|
|
] } });
|
|
const repo = createMockMessagesRepo();
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
// With 50% exploration rate, we can't guarantee the outcome,
|
|
// but we can verify it returns a valid strategy
|
|
const results = new Set<string>();
|
|
for (let i = 0; i < 20; i++) {
|
|
const result = selector.selectStrategy();
|
|
if (result) results.add(result.id);
|
|
}
|
|
expect(results.size).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('logs explore selection', () => {
|
|
const config = getTestConfig({ strategies: { explorationRate: 1.0, variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'prompt1' },
|
|
] } });
|
|
const repo = createMockMessagesRepo();
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
selector.selectStrategy();
|
|
const debugLogs = getLogsByLevel(logger, 'debug');
|
|
expect(debugLogs.some(l => l.message.includes('explore'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('exploitation mode', () => {
|
|
it('selects best performing strategy when all have enough samples', () => {
|
|
const stats: StrategyStats[] = [
|
|
{ strategyId: 'v1', totalSent: 10, totalResponses: 2, responseRate: 20 },
|
|
{ strategyId: 'v2', totalSent: 10, totalResponses: 5, responseRate: 50 },
|
|
{ strategyId: 'v3', totalSent: 10, totalResponses: 1, responseRate: 10 },
|
|
];
|
|
const repo = createMockMessagesRepo(stats);
|
|
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
|
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
|
{ id: 'v3', weight: 1, promptOverride: 'p3' },
|
|
] } });
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
const result = selector.selectStrategy();
|
|
expect(result).not.toBeNull();
|
|
expect(result!.id).toBe('v2');
|
|
expect(result!.reason).toBe('exploit');
|
|
});
|
|
|
|
it('selects first variant when all have equal response rates', () => {
|
|
const stats: StrategyStats[] = [
|
|
{ strategyId: 'v1', totalSent: 10, totalResponses: 5, responseRate: 50 },
|
|
{ strategyId: 'v2', totalSent: 10, totalResponses: 5, responseRate: 50 },
|
|
];
|
|
const repo = createMockMessagesRepo(stats);
|
|
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
|
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
|
] } });
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
const result = selector.selectStrategy();
|
|
expect(result).not.toBeNull();
|
|
// When rates are equal, the first one with the best rate wins
|
|
// Since v1 has rate 50 and v2 has rate 50, v1 wins (first encountered)
|
|
expect(result!.id).toBe('v1');
|
|
});
|
|
|
|
it('selects best strategy when some have 0 responses', () => {
|
|
const stats: StrategyStats[] = [
|
|
{ strategyId: 'v1', totalSent: 10, totalResponses: 0, responseRate: 0 },
|
|
{ strategyId: 'v2', totalSent: 10, totalResponses: 3, responseRate: 30 },
|
|
];
|
|
const repo = createMockMessagesRepo(stats);
|
|
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
|
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
|
] } });
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
const result = selector.selectStrategy();
|
|
expect(result!.id).toBe('v2');
|
|
});
|
|
|
|
it('logs exploit selection with response rate', () => {
|
|
const stats: StrategyStats[] = [
|
|
{ strategyId: 'v1', totalSent: 10, totalResponses: 5, responseRate: 50 },
|
|
];
|
|
const repo = createMockMessagesRepo(stats);
|
|
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
|
] } });
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
selector.selectStrategy();
|
|
const debugLogs = getLogsByLevel(logger, 'debug');
|
|
expect(debugLogs.some(l => l.message.includes('exploit'))).toBe(true);
|
|
expect(debugLogs.some(l => l.message.includes('50'))).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe('under-sampled strategies', () => {
|
|
it('balances selection toward the variant with the fewest mature and pending sends', () => {
|
|
const stats: StrategyStats[] = [
|
|
{ strategyId: 'v1', totalSent: 2, pendingSent: 5, totalResponses: 0, responseRate: 0 },
|
|
{ strategyId: 'v2', totalSent: 2, pendingSent: 1, totalResponses: 0, responseRate: 0 },
|
|
];
|
|
const repo = createMockMessagesRepo(stats);
|
|
const config = getTestConfig({ strategies: { explorationRate: 0, minSamples: 20, variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
|
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
|
] } });
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
expect(selector.selectStrategy()?.id).toBe('v2');
|
|
});
|
|
|
|
it('selects under-sampled variant even in exploit mode', () => {
|
|
const stats: StrategyStats[] = [
|
|
{ strategyId: 'v1', totalSent: 10, totalResponses: 2, responseRate: 20 },
|
|
// v2 has no stats (never used)
|
|
];
|
|
const repo = createMockMessagesRepo(stats);
|
|
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
|
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
|
] } });
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
// v2 has 0 samples which is < minSamples(5), so it should be selected
|
|
const result = selector.selectStrategy();
|
|
expect(result!.id).toBe('v2');
|
|
expect(result!.reason).toBe('explore');
|
|
});
|
|
|
|
it('selects under-sampled variant with insufficient samples', () => {
|
|
const stats: StrategyStats[] = [
|
|
{ strategyId: 'v1', totalSent: 3, totalResponses: 1, responseRate: 33 },
|
|
// v2 has no stats
|
|
];
|
|
const repo = createMockMessagesRepo(stats);
|
|
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
|
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
|
] } });
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
// Both variants are under-sampled (v1: 3 < 5, v2: 0 < 5) so selection
|
|
// is random between them. Just assert the explore branch is taken.
|
|
const result = selector.selectStrategy();
|
|
expect(['v1', 'v2']).toContain(result!.id);
|
|
expect(result!.reason).toBe('explore');
|
|
});
|
|
|
|
it('skips under-sampled check when all have enough samples', () => {
|
|
const stats: StrategyStats[] = [
|
|
{ strategyId: 'v1', totalSent: 10, totalResponses: 2, responseRate: 20 },
|
|
{ strategyId: 'v2', totalSent: 10, totalResponses: 8, responseRate: 80 },
|
|
];
|
|
const repo = createMockMessagesRepo(stats);
|
|
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
|
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
|
] } });
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
const result = selector.selectStrategy();
|
|
expect(result!.id).toBe('v2');
|
|
expect(result!.reason).toBe('exploit');
|
|
});
|
|
|
|
it('prioritizes unexplored over under-sampled', () => {
|
|
// v1 has 10 samples (above minSamples), v2 has none — only v2 is unexplored.
|
|
const stats: StrategyStats[] = [
|
|
{ strategyId: 'v1', totalSent: 10, totalResponses: 5, responseRate: 50 },
|
|
];
|
|
const repo = createMockMessagesRepo(stats);
|
|
const config = getTestConfig({ strategies: { explorationRate: 0.0, minSamples: 5, variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
|
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
|
] } });
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
const result = selector.selectStrategy();
|
|
expect(result!.id).toBe('v2');
|
|
});
|
|
});
|
|
|
|
describe('getStats', () => {
|
|
it('returns all configured variants', () => {
|
|
const stats: StrategyStats[] = [
|
|
{ strategyId: 'v1', totalSent: 5, totalResponses: 2, responseRate: 40 },
|
|
];
|
|
const repo = createMockMessagesRepo(stats);
|
|
const config = getTestConfig({ strategies: { variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
|
{ id: 'v2', weight: 1, promptOverride: 'p2' },
|
|
] } });
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
const result = selector.getStats();
|
|
expect(result.length).toBe(2);
|
|
expect(result[0].configured).toBe(true);
|
|
expect(result[1].configured).toBe(true);
|
|
});
|
|
|
|
it('includes zero counts for variants with no data', () => {
|
|
const repo = createMockMessagesRepo([]);
|
|
const config = getTestConfig({ strategies: { variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
|
] } });
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
const result = selector.getStats();
|
|
expect(result[0].totalSent).toBe(0);
|
|
expect(result[0].totalResponses).toBe(0);
|
|
expect(result[0].responseRate).toBe(0);
|
|
});
|
|
|
|
it('includes historical strategies not in config', () => {
|
|
const stats: StrategyStats[] = [
|
|
{ strategyId: 'old-strategy', totalSent: 10, totalResponses: 3, responseRate: 30 },
|
|
];
|
|
const repo = createMockMessagesRepo(stats);
|
|
const config = getTestConfig({ strategies: { variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
|
] } });
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
const result = selector.getStats();
|
|
expect(result.length).toBe(2);
|
|
const historical = result.find(r => r.strategyId === 'old-strategy');
|
|
expect(historical).toBeDefined();
|
|
expect(historical!.configured).toBe(false);
|
|
});
|
|
|
|
it('maps response rates correctly', () => {
|
|
const stats: StrategyStats[] = [
|
|
{ strategyId: 'v1', totalSent: 10, totalResponses: 3, responseRate: 30 },
|
|
];
|
|
const repo = createMockMessagesRepo(stats);
|
|
const config = getTestConfig({ strategies: { variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'p1' },
|
|
] } });
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
const result = selector.getStats();
|
|
expect(result[0].totalSent).toBe(10);
|
|
expect(result[0].totalResponses).toBe(3);
|
|
expect(result[0].responseRate).toBe(30);
|
|
});
|
|
});
|
|
|
|
describe('edge cases', () => {
|
|
it('handles single variant', () => {
|
|
const config = getTestConfig({ strategies: { explorationRate: 0.0, variants: [
|
|
{ id: 'only', weight: 1, promptOverride: 'only one' },
|
|
] } });
|
|
const repo = createMockMessagesRepo();
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
const result = selector.selectStrategy();
|
|
expect(result!.id).toBe('only');
|
|
});
|
|
|
|
it('handles many variants', () => {
|
|
const variants = Array.from({ length: 10 }, (_, i) => ({
|
|
id: `v${i}`,
|
|
weight: 1,
|
|
promptOverride: `prompt ${i}`,
|
|
}));
|
|
const config = getTestConfig({ strategies: { explorationRate: 1.0, variants } });
|
|
const repo = createMockMessagesRepo();
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
const results = new Set<string>();
|
|
for (let i = 0; i < 50; i++) {
|
|
const result = selector.selectStrategy();
|
|
if (result) results.add(result.id);
|
|
}
|
|
expect(results.size).toBeGreaterThan(1);
|
|
});
|
|
|
|
it('returns promptOverride from the selected variant', () => {
|
|
const config = getTestConfig({ strategies: { explorationRate: 1.0, variants: [
|
|
{ id: 'v1', weight: 1, promptOverride: 'My custom prompt' },
|
|
] } });
|
|
const repo = createMockMessagesRepo();
|
|
selector = new StrategySelector(config, repo, logger);
|
|
|
|
const result = selector.selectStrategy();
|
|
expect(result!.promptOverride).toBe('My custom prompt');
|
|
});
|
|
});
|
|
});
|