Skip to content

Commit 3e1f02b

Browse files
amarjaleelbanbhanmldangelo-oaimldangelo
authored
feat: add per-test repeat option (#9781)
Co-authored-by: Michael D'Angelo <mdangelo@openai.com> Co-authored-by: mldangelo <michael.l.dangelo@gmail.com>
1 parent f273300 commit 3e1f02b

10 files changed

Lines changed: 208 additions & 16 deletions

File tree

site/docs/configuration/test-cases.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,23 @@ tests:
6262
difficulty: easy
6363
```
6464
65+
### Repeating an Individual Test
66+
67+
Set `options.repeat` to a positive integer to run one test case multiple times:
68+
69+
```yaml title="promptfooconfig.yaml"
70+
tests:
71+
- description: 'Sample a nondeterministic response'
72+
vars:
73+
question: 'Write a short greeting'
74+
options:
75+
repeat: 3
76+
```
77+
78+
The per-test value overrides `--repeat`, `commandLineOptions.repeat`, or
79+
`evaluateOptions.repeat` for that test. Other tests continue to use the global repeat count.
80+
Repeat indexes use separate cache entries; add `--no-cache` when every run must call the provider.
81+
6582
### Filtering Tests by Provider
6683

6784
Control which providers run specific tests using the `providers` field. This allows you to run different test suites against different models in a single evaluation:

site/static/config-schema.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1187,6 +1187,11 @@
11871187
},
11881188
"runSerially": {
11891189
"type": "boolean"
1190+
},
1191+
"repeat": {
1192+
"type": "integer",
1193+
"exclusiveMinimum": 0,
1194+
"maximum": 9007199254740991
11901195
}
11911196
},
11921197
"additionalProperties": {}

src/evaluator.ts

Lines changed: 28 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -432,6 +432,12 @@ function getRepeatCacheNamespace(
432432
return undefined;
433433
}
434434

435+
function normalizeRepeatCount(repeat: number | undefined, fallback = 1): number {
436+
return typeof repeat === 'number' && Number.isSafeInteger(repeat) && repeat > 0
437+
? repeat
438+
: fallback;
439+
}
440+
435441
function hasGeneratedRedteamMetadata(test: AtomicTestCase): boolean {
436442
return (
437443
typeof test.metadata?.pluginId === 'string' &&
@@ -650,6 +656,17 @@ interface ProviderCallResult {
650656
traceContext: Awaited<ReturnType<typeof generateTraceContextIfNeeded>>;
651657
}
652658

659+
function mergeProviderPromptConfig(
660+
promptConfig: Prompt['config'],
661+
testOptions: AtomicTestCase['options'],
662+
): Prompt['config'] {
663+
const { repeat: _repeat, ...providerOptions } = testOptions ?? {};
664+
return {
665+
...(promptConfig ?? {}),
666+
...providerOptions,
667+
};
668+
}
669+
653670
function createRunEvalState({
654671
provider,
655672
prompt,
@@ -662,10 +679,7 @@ function createRunEvalState({
662679
const setup = createRunEvalSetup({
663680
provider,
664681
prompt,
665-
promptConfig: {
666-
...(prompt.config ?? {}),
667-
...(test.options ?? {}),
668-
},
682+
promptConfig: mergeProviderPromptConfig(prompt.config, test.options),
669683
vars,
670684
});
671685

@@ -804,10 +818,7 @@ async function renderRunEvalPrompt({
804818
if (isRedteam) {
805819
throwIfTargetPromptExceedsMaxChars(renderedPrompt, testSuite?.redteam?.maxCharsPerMessage);
806820
}
807-
const promptConfig = {
808-
...(promptForRender.config ?? {}),
809-
...(test.options ?? {}),
810-
};
821+
const promptConfig = mergeProviderPromptConfig(promptForRender.config, test.options);
811822
const setup = createRunEvalSetup({ provider, prompt: promptForRender, promptConfig, vars });
812823
setup.prompt.raw = renderedPrompt;
813824
return {
@@ -2170,6 +2181,7 @@ function mergeScenarioTest(
21702181
},
21712182
options: {
21722183
...(defaultTest?.options || {}),
2184+
...data.options,
21732185
...test.options,
21742186
},
21752187
assert: [...(data.assert || []), ...(test.assert || [])],
@@ -2386,13 +2398,19 @@ function appendRunEvalOptionsForTestCase({
23862398
? [testCase.vars]
23872399
: generateVarCombinations(testCase.vars || {});
23882400

2389-
for (let repeatIndex = 0; repeatIndex < (options.repeat || 1); repeatIndex++) {
2401+
const globalRepeat = normalizeRepeatCount(options.repeat);
2402+
const testRepeat = normalizeRepeatCount(testCase.options?.repeat, globalRepeat);
2403+
const effectiveOptions = {
2404+
...options,
2405+
repeat: testRepeat,
2406+
};
2407+
for (let repeatIndex = 0; repeatIndex < testRepeat; repeatIndex++) {
23902408
for (const vars of varCombinations) {
23912409
appendRunEvalOptionsForVars({
23922410
concurrency,
23932411
conversations,
23942412
evalId,
2395-
options,
2413+
options: effectiveOptions,
23962414
promptIdCache,
23972415
promptIndexMap,
23982416
promptPrefix,

src/types/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -907,6 +907,9 @@ export const TestCaseSchema = z.object({
907907
disableDefaultAsserts: z.boolean().optional(),
908908
// If true, run this without concurrency no matter what
909909
runSerially: z.boolean().optional(),
910+
911+
// Number of times to repeat this specific test case.
912+
repeat: z.number().int().positive().safe().optional(),
910913
})
911914
.catchall(z.any())
912915
.optional(),

test/config-schema.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,4 +352,22 @@ describe('config-schema.json', () => {
352352
}
353353
});
354354
});
355+
356+
describe('per-test repeat schema', () => {
357+
it('accepts positive integers and rejects invalid repeat counts', () => {
358+
const validate = ajv.compile(schema);
359+
const config = {
360+
prompts: ['hello'],
361+
providers: ['echo'],
362+
tests: [{ options: { repeat: 3 } }],
363+
};
364+
365+
expect(validate(config)).toBe(true);
366+
367+
for (const repeat of [0, -1, 1.5, Number.MAX_SAFE_INTEGER + 1]) {
368+
config.tests[0].options.repeat = repeat;
369+
expect(validate(config), `repeat ${repeat} should be invalid`).toBe(false);
370+
}
371+
});
372+
});
355373
});

test/evaluator/basic.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,4 +511,60 @@ describeEvaluator('evaluator basic flows', () => {
511511
expect(summary.results[0].prompt.label).toBe('Test prompt');
512512
expect(summary.results[0].response?.output).toBe('Test output');
513513
});
514+
it('allows test case repeat to override global repeat option', async () => {
515+
const testSuite: TestSuite = {
516+
providers: [mockApiProvider],
517+
prompts: [toPrompt('Test prompt')],
518+
tests: [
519+
{
520+
options: {
521+
repeat: 3,
522+
},
523+
},
524+
],
525+
};
526+
527+
const evalRecord = await Eval.create({}, testSuite.prompts, {
528+
id: randomUUID(),
529+
});
530+
531+
await evaluate(testSuite, evalRecord, { repeat: 2 });
532+
533+
const summary = await evalRecord.toEvaluateSummary();
534+
535+
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(3);
536+
expect(
537+
vi.mocked(mockApiProvider.callApi).mock.calls.map(([, context]) => context?.repeatIndex),
538+
).toEqual([0, 1, 2]);
539+
expect(summary.results).toHaveLength(3);
540+
});
541+
542+
it.each([
543+
['zero', 0],
544+
['negative', -1],
545+
['fractional', 1.5],
546+
['NaN', Number.NaN],
547+
['infinite', Number.POSITIVE_INFINITY],
548+
['unsafe', Number.MAX_SAFE_INTEGER + 1],
549+
])('falls back to global repeat for a %s per-test repeat', async (_label, repeat) => {
550+
const testSuite: TestSuite = {
551+
providers: [mockApiProvider],
552+
prompts: [toPrompt('Test prompt')],
553+
tests: [
554+
{
555+
options: {
556+
repeat,
557+
},
558+
},
559+
],
560+
};
561+
562+
const evalRecord = await Eval.create({}, testSuite.prompts, {
563+
id: randomUUID(),
564+
});
565+
566+
await evaluate(testSuite, evalRecord, { repeat: 2 });
567+
568+
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(2);
569+
});
514570
});

test/evaluator/repeatCache.test.ts

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,15 @@ describeEvaluator('evaluator repeat cache isolation', () => {
7171
expect(contexts[0]!.bustCache).toBeFalsy();
7272
});
7373

74-
it('isolates manual provider cache entries by repeat index', async () => {
74+
it('isolates per-test repeat provider cache entries from the default namespace', async () => {
7575
await clearCache();
7676

77+
const baselineResponse: ProviderResponse = {
78+
output: 'baseline-result',
79+
tokenUsage: createEmptyTokenUsage(),
80+
};
81+
await getCache().set('manual-provider-key', baselineResponse);
82+
7783
let cacheMissCount = 0;
7884
const provider: ApiProvider = {
7985
id: () => 'mock-provider',
@@ -103,11 +109,11 @@ describeEvaluator('evaluator repeat cache isolation', () => {
103109
const testSuite: TestSuite = {
104110
providers: [provider],
105111
prompts: [toPrompt('Test prompt')],
106-
tests: [{}],
112+
tests: [{ options: { repeat: 2 } }],
107113
};
108114

109115
const firstEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
110-
await evaluate(testSuite, firstEval, { maxConcurrency: 1, repeat: 2 });
116+
await evaluate(testSuite, firstEval, { maxConcurrency: 1 });
111117
const firstSummary = await firstEval.toEvaluateSummary();
112118

113119
expect(cacheMissCount).toBe(2);
@@ -118,7 +124,7 @@ describeEvaluator('evaluator repeat cache isolation', () => {
118124
expect(firstSummary.results.map((result) => result.response?.cached)).toEqual([false, false]);
119125

120126
const secondEval = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
121-
await evaluate(testSuite, secondEval, { maxConcurrency: 1, repeat: 2 });
127+
await evaluate(testSuite, secondEval, { maxConcurrency: 1 });
122128
const secondSummary = await secondEval.toEvaluateSummary();
123129

124130
expect(cacheMissCount).toBe(2);
@@ -127,6 +133,7 @@ describeEvaluator('evaluator repeat cache isolation', () => {
127133
'result-repeat-1-miss-2',
128134
]);
129135
expect(secondSummary.results.map((result) => result.response?.cached)).toEqual([true, true]);
136+
expect(await getCache().get('manual-provider-key')).toEqual(baselineResponse);
130137
});
131138

132139
it('isolates beforeEach extension cache entries by repeat index', async () => {

test/evaluator/runEval.test.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -193,14 +193,14 @@ describe('runEval', () => {
193193
...defaultOptions,
194194
provider: mockProvider,
195195
prompt: promptWithFunction,
196-
test: { options: { temperature: 0.9 } },
196+
test: { options: { repeat: 3, temperature: 0.9 } },
197197
conversations: {},
198198
registers: {},
199199
});
200200
const result = results[0];
201201
expect(result.success).toBe(true);
202202

203-
// test.options should override dynamic config
203+
// Provider options should override dynamic config, while evaluator-only options stay internal.
204204
const callApiMock = vi.mocked(mockProvider.callApi);
205205
const callApiArgs = callApiMock.mock.calls[0];
206206
expect(callApiArgs).toBeDefined();

test/evaluator/scenarios.test.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,62 @@ describeEvaluator('evaluator scenarios and conversations', () => {
6868
expect(summary.results[1].response?.output).toBe('Bonjour le monde');
6969
});
7070

71+
it('applies repeat from scenario config options', async () => {
72+
const mockApiProvider: ApiProvider = {
73+
id: vi.fn().mockReturnValue('test-provider'),
74+
callApi: vi.fn().mockResolvedValue({
75+
output: 'Hello',
76+
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
77+
}),
78+
};
79+
80+
const testSuite: TestSuite = {
81+
providers: [mockApiProvider],
82+
prompts: [toPrompt('Test prompt')],
83+
scenarios: [
84+
{
85+
config: [{ options: { repeat: 3 } }],
86+
tests: [{}],
87+
},
88+
],
89+
};
90+
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
91+
92+
await evaluate(testSuite, evalRecord, { repeat: 1 });
93+
const summary = await evalRecord.toEvaluateSummary();
94+
95+
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(3);
96+
expect(summary.results).toHaveLength(3);
97+
});
98+
99+
it('lets scenario test options.repeat override scenario config options.repeat', async () => {
100+
const mockApiProvider: ApiProvider = {
101+
id: vi.fn().mockReturnValue('test-provider'),
102+
callApi: vi.fn().mockResolvedValue({
103+
output: 'Hello',
104+
tokenUsage: { total: 10, prompt: 5, completion: 5, cached: 0, numRequests: 1 },
105+
}),
106+
};
107+
108+
const testSuite: TestSuite = {
109+
providers: [mockApiProvider],
110+
prompts: [toPrompt('Test prompt')],
111+
scenarios: [
112+
{
113+
config: [{ options: { repeat: 5 } }],
114+
tests: [{ options: { repeat: 2 } }],
115+
},
116+
],
117+
};
118+
const evalRecord = await Eval.create({}, testSuite.prompts, { id: randomUUID() });
119+
120+
await evaluate(testSuite, evalRecord, { repeat: 1 });
121+
const summary = await evalRecord.toEvaluateSummary();
122+
123+
expect(mockApiProvider.callApi).toHaveBeenCalledTimes(2);
124+
expect(summary.results).toHaveLength(2);
125+
});
126+
71127
it('evaluate with scenarios and multiple vars', async () => {
72128
const mockApiProvider: ApiProvider = {
73129
id: vi.fn().mockReturnValue('test-provider'),

test/types/index.test.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -358,12 +358,24 @@ describe('TestCaseSchema options (merged schema properties)', () => {
358358
options: {
359359
disableVarExpansion: true,
360360
disableConversationVar: true,
361+
repeat: 3,
361362
runSerially: true,
362363
},
363364
};
364365
expect(() => TestCaseSchema.parse(testCase)).not.toThrow();
365366
});
366367

368+
it.each([
369+
0,
370+
-1,
371+
1.5,
372+
Number.NaN,
373+
Number.POSITIVE_INFINITY,
374+
Number.MAX_SAFE_INTEGER + 1,
375+
])('should reject invalid per-test repeat %s', (repeat) => {
376+
expect(TestCaseSchema.safeParse({ options: { repeat } }).success).toBe(false);
377+
});
378+
367379
it('should validate options combining properties from ALL merged schemas', () => {
368380
// This is the critical test - properties from different sub-schemas must work together
369381
const testCase = {

0 commit comments

Comments
 (0)