Skip to content

Commit a045834

Browse files
authored
feat(config): lower scenarios into tests (#1693)
1 parent 4c5c645 commit a045834

7 files changed

Lines changed: 1350 additions & 16 deletions

File tree

packages/core/src/evaluation/validation/eval-file.schema.ts

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -836,25 +836,13 @@ const ConfigDefaultsSchema = z
836836
})
837837
.strict();
838838

839-
const ScenarioConfigSchema = z
840-
.object({
841-
vars: JsonObjectSchema.optional(),
842-
provider: EvalTargetSchema.optional(),
843-
providers: EvalTargetsSchema.optional(),
844-
prompts: PromptsSchema.optional(),
845-
provider_output: ExpectedOutputSchema.optional(),
846-
assert: z.array(AssertionItemSchema).optional(),
847-
options: JsonObjectSchema.optional(),
848-
threshold: z.number().min(0).max(1).optional(),
849-
metadata: z.record(z.unknown()).optional(),
850-
})
851-
.passthrough();
839+
const ScenarioConfigSchema = EvalTestSchema.partial();
852840

853841
const ScenarioSchema = z
854842
.object({
855843
description: z.string().optional(),
856-
config: z.array(ScenarioConfigSchema).optional(),
857-
tests: z.array(EvalTestSchema).optional(),
844+
config: z.array(ScenarioConfigSchema),
845+
tests: z.array(EvalTestSchema),
858846
})
859847
.strict();
860848

@@ -900,6 +888,16 @@ export const EvalFileSchemaInput: z.ZodType = z.object({
900888
imports: z.never({ invalid_type_error: TOP_LEVEL_IMPORTS_MESSAGE }).optional(),
901889
// Tests (inline raw cases, legacy include entries, or external raw-case path)
902890
tests: TestsSchema.optional(),
891+
providerPromptMap: z
892+
.never({
893+
invalid_type_error: "Top-level 'providerPromptMap' is not supported. Use 'targets'.",
894+
})
895+
.optional(),
896+
provider_prompt_map: z
897+
.never({
898+
invalid_type_error: "Top-level 'provider_prompt_map' is not supported. Use 'targets'.",
899+
})
900+
.optional(),
903901
// Shared composable config graph fields
904902
graders: z.union([z.array(ConfigGraderSchema), z.string().min(1)]).optional(),
905903
defaults: z.union([ConfigDefaultsSchema, z.string().min(1)]).optional(),

packages/core/src/evaluation/validation/eval-validator.ts

Lines changed: 236 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -335,6 +335,8 @@ const REMOVED_TOP_LEVEL_FIELDS = new Map<string, string>([
335335
'preprocessors',
336336
"Top-level 'preprocessors' has been removed from authored eval YAML. Use default_test.options.transform or assertion-level transform instead.",
337337
],
338+
['providerPromptMap', "Top-level 'providerPromptMap' is not supported. Use 'targets'."],
339+
['provider_prompt_map', "Top-level 'provider_prompt_map' is not supported. Use 'targets'."],
338340
]);
339341

340342
/** Deprecated top-level fields with migration hints. */
@@ -374,6 +376,7 @@ const KNOWN_TEST_FIELDS = new Set([
374376
'window_size',
375377
]);
376378

379+
const KNOWN_SCENARIO_FIELDS = new Set(['description', 'config', 'tests']);
377380
const SUPPORTED_WORKSPACE_REPO_FIELDS = new Set(['path', 'repo', 'commit', 'ancestor', 'sparse']);
378381
const KNOWN_REMOVED_WORKSPACE_FIELDS = new Set([
379382
'template',
@@ -393,6 +396,22 @@ REMOVED_TEST_FIELDS.set(
393396
'input',
394397
"tests[].input has been removed from authored eval YAML. Put prompt text or chat/system/user messages in top-level 'prompts' and put row-specific data in tests[].vars.",
395398
);
399+
REMOVED_TEST_FIELDS.set(
400+
'preprocessors',
401+
'tests[].preprocessors has been removed from authored eval YAML. Use tests[].options.transform or assertion-level transform instead.',
402+
);
403+
REMOVED_TEST_FIELDS.set(
404+
'postprocess',
405+
'tests[].postprocess has been removed from authored eval YAML. Use tests[].options.transform instead.',
406+
);
407+
REMOVED_TEST_FIELDS.set(
408+
'eval_cases',
409+
"tests[].eval_cases has been removed from authored eval YAML. Use 'tests' rows directly.",
410+
);
411+
REMOVED_TEST_FIELDS.set(
412+
'evalcases',
413+
"tests[].evalcases has been removed from authored eval YAML. Use 'tests' rows directly.",
414+
);
396415

397416
/** Deprecated test-level fields with migration hints. */
398417
const DEPRECATED_TEST_FIELDS = new Map<string, string>([
@@ -570,6 +589,7 @@ export async function validateEvalFile(filePath: string): Promise<ValidationResu
570589
validateEvaluateOptions(parsed.evaluate_options, 'evaluate_options', absolutePath, errors);
571590
validateAssertArray(parsed.assert, 'assert', absolutePath, errors, customAssertionTypes);
572591
validateDefaultTest(parsed.default_test, absolutePath, errors, customAssertionTypes);
592+
await validateScenarios(parsed.scenarios, parsed, absolutePath, errors, customAssertionTypes);
573593
const cases: JsonValue | undefined = parsed.tests;
574594
const hasImports = collectImportEntries(parsed).length > 0;
575595
const hasScenarios = Array.isArray(parsed.scenarios);
@@ -1437,6 +1457,222 @@ function validateDefaultTest(
14371457
}
14381458
}
14391459

1460+
async function validateScenarios(
1461+
scenarios: JsonValue | undefined,
1462+
parsed: JsonObject,
1463+
filePath: string,
1464+
errors: ValidationError[],
1465+
customAssertionTypes: ReadonlySet<string>,
1466+
): Promise<void> {
1467+
if (scenarios === undefined) {
1468+
return;
1469+
}
1470+
if (!Array.isArray(scenarios)) {
1471+
errors.push({
1472+
severity: 'error',
1473+
filePath,
1474+
location: 'scenarios',
1475+
message: "Invalid 'scenarios' field (must be an array)",
1476+
});
1477+
return;
1478+
}
1479+
1480+
for (let scenarioIndex = 0; scenarioIndex < scenarios.length; scenarioIndex++) {
1481+
const scenario = scenarios[scenarioIndex];
1482+
const scenarioLocation = `scenarios[${scenarioIndex}]`;
1483+
if (!isObject(scenario)) {
1484+
errors.push({
1485+
severity: 'error',
1486+
filePath,
1487+
location: scenarioLocation,
1488+
message: 'Scenario must be an object',
1489+
});
1490+
continue;
1491+
}
1492+
1493+
for (const key of Object.keys(scenario)) {
1494+
if (!KNOWN_SCENARIO_FIELDS.has(key)) {
1495+
errors.push({
1496+
severity: 'error',
1497+
filePath,
1498+
location: `${scenarioLocation}.${key}`,
1499+
message: `Unknown scenario field '${key}'.`,
1500+
});
1501+
}
1502+
}
1503+
1504+
if (!Array.isArray(scenario.config)) {
1505+
errors.push({
1506+
severity: 'error',
1507+
filePath,
1508+
location: `${scenarioLocation}.config`,
1509+
message: "Invalid 'config' field (must be an array)",
1510+
});
1511+
} else {
1512+
for (let configIndex = 0; configIndex < scenario.config.length; configIndex++) {
1513+
await validateScenarioTestLikeRow(
1514+
scenario.config[configIndex],
1515+
`${scenarioLocation}.config[${configIndex}]`,
1516+
filePath,
1517+
errors,
1518+
customAssertionTypes,
1519+
{ requireInput: false },
1520+
);
1521+
}
1522+
}
1523+
1524+
const scenarioConfigs = Array.isArray(scenario.config) ? scenario.config : [];
1525+
1526+
if (!Array.isArray(scenario.tests)) {
1527+
errors.push({
1528+
severity: 'error',
1529+
filePath,
1530+
location: `${scenarioLocation}.tests`,
1531+
message: "Invalid 'tests' field (must be an array)",
1532+
});
1533+
continue;
1534+
}
1535+
1536+
for (let testIndex = 0; testIndex < scenario.tests.length; testIndex++) {
1537+
const test = scenario.tests[testIndex];
1538+
const everyConfigProvidesInput =
1539+
scenarioConfigs.length > 0 &&
1540+
scenarioConfigs.every(
1541+
(config) =>
1542+
isObject(config) &&
1543+
(config.prompts !== undefined || config.provider_output !== undefined),
1544+
);
1545+
const requireInput =
1546+
isObject(test) &&
1547+
parsed.prompts === undefined &&
1548+
test.prompts === undefined &&
1549+
test.provider_output === undefined &&
1550+
!everyConfigProvidesInput;
1551+
await validateScenarioTestLikeRow(
1552+
test,
1553+
`${scenarioLocation}.tests[${testIndex}]`,
1554+
filePath,
1555+
errors,
1556+
customAssertionTypes,
1557+
{ requireInput },
1558+
);
1559+
}
1560+
}
1561+
}
1562+
1563+
async function validateScenarioTestLikeRow(
1564+
row: JsonValue | undefined,
1565+
location: string,
1566+
filePath: string,
1567+
errors: ValidationError[],
1568+
customAssertionTypes: ReadonlySet<string>,
1569+
options: { readonly requireInput: boolean },
1570+
): Promise<void> {
1571+
if (!isObject(row)) {
1572+
errors.push({
1573+
severity: 'error',
1574+
filePath,
1575+
location,
1576+
message: 'Scenario row must be an object',
1577+
});
1578+
return;
1579+
}
1580+
1581+
for (const key of Object.keys(row)) {
1582+
const removedMessage = REMOVED_TEST_FIELDS.get(key);
1583+
if (removedMessage) {
1584+
errors.push({
1585+
severity: 'error',
1586+
filePath,
1587+
location: `${location}.${key}`,
1588+
message: removedMessage.replaceAll('tests[]', location),
1589+
});
1590+
continue;
1591+
}
1592+
const deprecationMessage = DEPRECATED_TEST_FIELDS.get(key);
1593+
if (deprecationMessage) {
1594+
errors.push({
1595+
severity: 'warning',
1596+
filePath,
1597+
location: `${location}.${key}`,
1598+
message: deprecationMessage,
1599+
});
1600+
} else if (!KNOWN_TEST_FIELDS.has(key)) {
1601+
errors.push({
1602+
severity: 'error',
1603+
filePath,
1604+
location: `${location}.${key}`,
1605+
message: `Unknown test field '${key}'.`,
1606+
});
1607+
}
1608+
}
1609+
1610+
const id = row.id;
1611+
if (id !== undefined && (typeof id !== 'string' || id.trim().length === 0)) {
1612+
errors.push({
1613+
severity: 'error',
1614+
filePath,
1615+
location: `${location}.id`,
1616+
message: "Invalid 'id' field (must be a non-empty string when provided)",
1617+
});
1618+
}
1619+
1620+
const criteria = row.criteria;
1621+
if (criteria !== undefined && (typeof criteria !== 'string' || criteria.trim().length === 0)) {
1622+
errors.push({
1623+
severity: 'error',
1624+
filePath,
1625+
location: `${location}.criteria`,
1626+
message: "Invalid 'criteria' field (must be a non-empty string if provided)",
1627+
});
1628+
} else if (criteria !== undefined && row.assert !== undefined) {
1629+
errors.push({
1630+
severity: 'error',
1631+
filePath,
1632+
location: `${location}.criteria`,
1633+
message:
1634+
"Do not combine test-level 'criteria' with 'assert'. Put human-readable case descriptions in tests[].description, or express grading text as an explicit assertion such as { type: 'llm-rubric', value: ... }.",
1635+
});
1636+
}
1637+
1638+
validateInputField(row.input, `${location}.input`, filePath, errors, {
1639+
required: options.requireInput,
1640+
});
1641+
1642+
if (row.expected_output !== undefined) {
1643+
errors.push({
1644+
severity: 'error',
1645+
filePath,
1646+
location: `${location}.expected_output`,
1647+
message:
1648+
"tests[].expected_output has been removed from authored eval YAML. Put the reference answer in tests[].vars.expected_output and consume it with an explicit assertion, for example { type: 'llm-rubric', value: 'Matches the reference answer: {{ expected_output }}' }.",
1649+
});
1650+
}
1651+
1652+
validateAssertArray(row.assert, `${location}.assert`, filePath, errors, customAssertionTypes);
1653+
validateRunOverride(row.run, `${location}.run`, filePath, errors);
1654+
validateTestOptions(row.options, `${location}.options`, filePath, errors);
1655+
await validateEnvironmentConfig(row.environment, filePath, errors, `${location}.environment`);
1656+
validateConversationMode(row, location, filePath, errors);
1657+
await validateWorkspaceConfig(row.workspace, filePath, errors, `${location}.workspace`);
1658+
1659+
const caseExecution = isObject(row.execution) ? row.execution : undefined;
1660+
if (caseExecution) {
1661+
validateTestExecutionFields(caseExecution, filePath, errors, location);
1662+
rejectRuntimeWorkspaceConfig(
1663+
caseExecution.workspace,
1664+
filePath,
1665+
errors,
1666+
`${location}.execution.workspace`,
1667+
);
1668+
}
1669+
1670+
const targets = row.providers !== undefined ? row.providers : row.provider;
1671+
if (targets !== undefined) {
1672+
validateTargetTestbedFields(targets, `${location}.providers`, filePath, errors);
1673+
}
1674+
}
1675+
14401676
function validateEvaluateOptions(
14411677
evaluateOptions: JsonValue | undefined,
14421678
location: string,

0 commit comments

Comments
 (0)