Skip to content

Commit ea71807

Browse files
committed
feat(sdk): add synonym field mapping for experiment evaluator inputs
1 parent 9df89a0 commit ea71807

File tree

5 files changed

+533
-43
lines changed

5 files changed

+533
-43
lines changed

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"devDependencies": {
1212
"@apidevtools/swagger-parser": "^10.1.0",
1313
"@types/node": "^24.0.15",
14+
"openapi-types": "^12.1.3",
1415
"openapi-typescript": "^7.4.0",
1516
"ts-node": "^10.9.2",
1617
"@commitlint/cli": "^19.8.1",
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
/**
2+
* Field synonym mapping for evaluator input normalization.
3+
*/
4+
5+
const SYNONYM_GROUPS: Set<string>[] = [
6+
new Set(["text", "completion", "answer", "response"]),
7+
new Set(["reference", "ground_truth", "context"]),
8+
new Set(["question", "prompt", "instructions", "query"]),
9+
new Set(["prompts", "trajectory_prompts"]),
10+
new Set(["completions", "trajectory_completions"]),
11+
];
12+
13+
/**
14+
* Get all synonyms for a given field name, including the field itself.
15+
*/
16+
export function getFieldSynonyms(field: string): Set<string> {
17+
for (const group of SYNONYM_GROUPS) {
18+
if (group.has(field)) return group;
19+
}
20+
return new Set([field]);
21+
}
22+
23+
/**
24+
* Normalize task output field names to match required evaluator fields using synonym mapping.
25+
*/
26+
export function normalizeTaskOutput(
27+
taskOutput: Record<string, unknown>,
28+
requiredFields: string[],
29+
): Record<string, unknown> {
30+
const normalized: Record<string, unknown> = {};
31+
const mappedKeys = new Set<string>();
32+
33+
// First pass: map required fields from task output via exact match or synonym lookup
34+
for (const requiredField of requiredFields) {
35+
// Exact match takes priority
36+
if (Object.prototype.hasOwnProperty.call(taskOutput, requiredField)) {
37+
normalized[requiredField] = taskOutput[requiredField];
38+
mappedKeys.add(requiredField);
39+
continue;
40+
}
41+
42+
// Look for a synonym present in task output
43+
const synonyms = getFieldSynonyms(requiredField);
44+
let foundKey: string | undefined;
45+
for (const synonym of synonyms) {
46+
if (Object.prototype.hasOwnProperty.call(taskOutput, synonym)) {
47+
foundKey = synonym;
48+
break;
49+
}
50+
}
51+
52+
if (foundKey !== undefined) {
53+
normalized[requiredField] = taskOutput[foundKey];
54+
mappedKeys.add(foundKey);
55+
}
56+
}
57+
58+
// Second pass: preserve fields that were not consumed by the mapping
59+
for (const [key, value] of Object.entries(taskOutput)) {
60+
if (
61+
!mappedKeys.has(key) &&
62+
!Object.prototype.hasOwnProperty.call(normalized, key)
63+
) {
64+
normalized[key] = value;
65+
}
66+
}
67+
68+
return normalized;
69+
}
70+
71+
/**
72+
* Get suggestions for a missing required field based on the available fields and synonym groups.
73+
*/
74+
export function getFieldSuggestions(
75+
missingField: string,
76+
availableFields: string[],
77+
): string[] {
78+
const suggestions: string[] = [];
79+
const missingFieldSynonyms = getFieldSynonyms(missingField);
80+
81+
for (const available of availableFields) {
82+
const availableSynonyms = getFieldSynonyms(available);
83+
for (const s of missingFieldSynonyms) {
84+
if (availableSynonyms.has(s)) {
85+
suggestions.push(available);
86+
break;
87+
}
88+
}
89+
}
90+
91+
return suggestions;
92+
}
93+
94+
/**
95+
* Format a help string showing all accepted synonyms for a required field.
96+
*/
97+
export function formatFieldHelp(field: string): string {
98+
const synonyms = getFieldSynonyms(field);
99+
if (synonyms.size === 1) {
100+
return `'${field}'`;
101+
}
102+
const others = [...synonyms]
103+
.filter((s) => s !== field)
104+
.sort()
105+
.map((s) => `'${s}'`)
106+
.join(", ");
107+
return `'${field}' (or synonyms: ${others})`;
108+
}
109+
110+
/**
111+
* Minimal shape required to perform validation.
112+
* Avoids importing from experiment.interface to prevent circular dependencies.
113+
*/
114+
export interface EvaluatorWithRequiredFields {
115+
name?: string;
116+
requiredInputFields?: string[];
117+
}
118+
119+
/**
120+
* Validate that task output contains all required fields for the given evaluators,
121+
* normalizing field names via synonym mapping first.
122+
*
123+
* Only evaluators that have requiredInputFields defined are validated.
124+
* Evaluators without requiredInputFields (e.g. plain string slugs resolved externally)
125+
* are skipped.
126+
*/
127+
export function validateAndNormalizeTaskOutput(
128+
taskOutput: Record<string, unknown>,
129+
evaluators: EvaluatorWithRequiredFields[],
130+
): Record<string, unknown> {
131+
const evaluatorsWithFields = evaluators.filter(
132+
(e) => e.requiredInputFields && e.requiredInputFields.length > 0,
133+
);
134+
135+
if (evaluatorsWithFields.length === 0) {
136+
return taskOutput;
137+
}
138+
139+
// Collect all required fields across all evaluators for normalization
140+
const allRequiredFields: string[] = [];
141+
for (const evaluator of evaluatorsWithFields) {
142+
allRequiredFields.push(...(evaluator.requiredInputFields ?? []));
143+
}
144+
145+
const normalizedOutput = normalizeTaskOutput(taskOutput, allRequiredFields);
146+
147+
// Validate: check which required fields are still missing after normalization
148+
const missingFieldsByEvaluator = new Map<string, string[]>();
149+
150+
for (const evaluator of evaluatorsWithFields) {
151+
if (!evaluator.requiredInputFields) continue;
152+
153+
const missingFields = evaluator.requiredInputFields.filter(
154+
(field) => !Object.prototype.hasOwnProperty.call(normalizedOutput, field),
155+
);
156+
157+
if (missingFields.length > 0) {
158+
const key = evaluator.name ?? "evaluator";
159+
const existing = missingFieldsByEvaluator.get(key) ?? [];
160+
missingFieldsByEvaluator.set(key, [...existing, ...missingFields]);
161+
}
162+
}
163+
164+
if (missingFieldsByEvaluator.size > 0) {
165+
let message = "Task output missing required fields for evaluators:";
166+
167+
for (const [slug, fields] of missingFieldsByEvaluator) {
168+
message += `\n - ${slug} requires:`;
169+
for (const field of [...fields].sort()) {
170+
const suggestions = getFieldSuggestions(field, Object.keys(taskOutput));
171+
const fieldHelp = formatFieldHelp(field);
172+
if (suggestions.length > 0) {
173+
message += `\n ${fieldHelp} - Did you mean: ${suggestions.join(", ")}?`;
174+
} else {
175+
message += `\n ${fieldHelp}`;
176+
}
177+
}
178+
}
179+
180+
message += `\n\nTask output contains: ${JSON.stringify(Object.keys(taskOutput))}`;
181+
message +=
182+
"\n\nHint: Update your task function to return an object with the required fields.";
183+
184+
throw new Error(message);
185+
}
186+
187+
return normalizedOutput;
188+
}

0 commit comments

Comments
 (0)