This commit is contained in:
545
packages/agent/tests/evolve-schema.test.ts
Normal file
545
packages/agent/tests/evolve-schema.test.ts
Normal file
@@ -0,0 +1,545 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
EvolveSchema,
|
||||
addOutputField,
|
||||
removeField,
|
||||
editFieldDescription,
|
||||
changeFieldType,
|
||||
addConstraint,
|
||||
removeConstraint,
|
||||
reorderFields,
|
||||
replaceOutputFields,
|
||||
schemaComplexity,
|
||||
aggregateSchemaScores,
|
||||
paretoFrontSchema,
|
||||
pickSchemaWinner,
|
||||
generateStructureMutations,
|
||||
generateOrderMutations,
|
||||
generateRefinementMutations,
|
||||
pickSample,
|
||||
type Schema,
|
||||
type SchemaField,
|
||||
type SchemaCandidate,
|
||||
type SchemaCandidateScore,
|
||||
type SchemaExecuteFn,
|
||||
} from '../src/evolve-schema.js';
|
||||
import type { EvalExample } from '../src/eval-dataset.js';
|
||||
import type { JudgeScore } from '../src/judge.js';
|
||||
|
||||
// ── Fixtures ───────────────────────────────────────────────────
|
||||
|
||||
function makeField(name: string, partial: Partial<SchemaField> = {}): SchemaField {
|
||||
return {
|
||||
name,
|
||||
type: 'string',
|
||||
description: `description for ${name}`,
|
||||
required: true,
|
||||
constraints: [],
|
||||
...partial,
|
||||
};
|
||||
}
|
||||
|
||||
function makeSchema(fields: string[]): Schema {
|
||||
return {
|
||||
name: 'test',
|
||||
fields: fields.map(f => makeField(f)),
|
||||
version: 1,
|
||||
};
|
||||
}
|
||||
|
||||
function makeExamples(n: number): EvalExample[] {
|
||||
return Array.from({ length: n }, (_, i) => ({
|
||||
input: `q${i}`,
|
||||
expected_output: `a${i}`,
|
||||
metadata: { source: 'trace' as const },
|
||||
}));
|
||||
}
|
||||
|
||||
function makeJudgeScore(overall: number, feedback = 'ok'): JudgeScore {
|
||||
return {
|
||||
overall, weighted: overall,
|
||||
correctness: overall, procedureFollowing: overall, conciseness: overall,
|
||||
lengthPenalty: 1, feedback, parsed: true,
|
||||
};
|
||||
}
|
||||
|
||||
function makeCandidate(id: string, schema: Schema, score: SchemaCandidateScore | null): SchemaCandidate {
|
||||
return {
|
||||
id,
|
||||
schema,
|
||||
generation: 0,
|
||||
parent: null,
|
||||
mutation: 'baseline',
|
||||
mutationLabel: 'baseline',
|
||||
score,
|
||||
perExample: [],
|
||||
};
|
||||
}
|
||||
|
||||
// Fake executor: returns a deterministic output string that scores higher
|
||||
// when schema has more fields (up to a cap) and scores lower when schema
|
||||
// is bloated beyond 5 fields.
|
||||
function makeFakeExecutor(bias: 'prefers-more' | 'prefers-less' | 'flat' = 'prefers-more'): SchemaExecuteFn {
|
||||
return async ({ schema }) => ({
|
||||
actual: schema.fields.map(f => `${f.name}=mock`).join('; '),
|
||||
parsed: schema.fields.length > 0,
|
||||
});
|
||||
}
|
||||
|
||||
function makeJudge(bias: 'prefers-more' | 'prefers-less' | 'flat' = 'prefers-more') {
|
||||
return {
|
||||
async score(args: { input: string; expected: string; actual: string }): Promise<JudgeScore> {
|
||||
const fieldCount = (args.actual.match(/=/g) || []).length;
|
||||
let overall: number;
|
||||
if (bias === 'prefers-more') {
|
||||
// More fields = better, up to 6 fields, then flat
|
||||
overall = Math.min(1, fieldCount / 6);
|
||||
} else if (bias === 'prefers-less') {
|
||||
overall = Math.max(0, 1 - fieldCount / 10);
|
||||
} else {
|
||||
overall = 0.5;
|
||||
}
|
||||
return makeJudgeScore(overall);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// ── Pure mutation functions ────────────────────────────────────
|
||||
|
||||
describe('addOutputField', () => {
|
||||
it('appends when no position given', () => {
|
||||
const s = makeSchema(['a', 'b']);
|
||||
const next = addOutputField(s, makeField('c'));
|
||||
expect(next.fields.map(f => f.name)).toEqual(['a', 'b', 'c']);
|
||||
expect(next.version).toBe(2);
|
||||
});
|
||||
|
||||
it('inserts at given position', () => {
|
||||
const s = makeSchema(['a', 'c']);
|
||||
const next = addOutputField(s, makeField('b'), 1);
|
||||
expect(next.fields.map(f => f.name)).toEqual(['a', 'b', 'c']);
|
||||
});
|
||||
|
||||
it('does not mutate source', () => {
|
||||
const s = makeSchema(['a']);
|
||||
addOutputField(s, makeField('b'));
|
||||
expect(s.fields.map(f => f.name)).toEqual(['a']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('removeField', () => {
|
||||
it('drops the named field', () => {
|
||||
const s = makeSchema(['a', 'b', 'c']);
|
||||
const next = removeField(s, 'b');
|
||||
expect(next.fields.map(f => f.name)).toEqual(['a', 'c']);
|
||||
expect(next.version).toBe(2);
|
||||
});
|
||||
|
||||
it('is a no-op for unknown field', () => {
|
||||
const s = makeSchema(['a', 'b']);
|
||||
const next = removeField(s, 'z');
|
||||
expect(next.fields.map(f => f.name)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('editFieldDescription', () => {
|
||||
it('updates the description of the named field', () => {
|
||||
const s = makeSchema(['a']);
|
||||
const next = editFieldDescription(s, 'a', 'new');
|
||||
expect(next.fields[0].description).toBe('new');
|
||||
});
|
||||
});
|
||||
|
||||
describe('changeFieldType', () => {
|
||||
it('updates the type of the named field', () => {
|
||||
const s = makeSchema(['a']);
|
||||
const next = changeFieldType(s, 'a', 'number');
|
||||
expect(next.fields[0].type).toBe('number');
|
||||
});
|
||||
});
|
||||
|
||||
describe('addConstraint / removeConstraint', () => {
|
||||
it('adds a constraint', () => {
|
||||
const s = makeSchema(['a']);
|
||||
const next = addConstraint(s, 'a', { kind: 'maxLength', value: 10 });
|
||||
expect(next.fields[0].constraints).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('removes the given constraint index', () => {
|
||||
const s = addConstraint(makeSchema(['a']), 'a', { kind: 'maxLength', value: 10 });
|
||||
const next = removeConstraint(s, 'a', 0);
|
||||
expect(next.fields[0].constraints).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('is a no-op for unknown field when adding', () => {
|
||||
const s = makeSchema(['a']);
|
||||
const next = addConstraint(s, 'z', { kind: 'maxLength', value: 10 });
|
||||
expect(next.fields[0].constraints).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('reorderFields', () => {
|
||||
it('reorders by name', () => {
|
||||
const s = makeSchema(['a', 'b', 'c']);
|
||||
const next = reorderFields(s, ['c', 'a', 'b']);
|
||||
expect(next.fields.map(f => f.name)).toEqual(['c', 'a', 'b']);
|
||||
});
|
||||
|
||||
it('appends fields omitted from newOrder', () => {
|
||||
const s = makeSchema(['a', 'b', 'c']);
|
||||
const next = reorderFields(s, ['b']);
|
||||
// 'b' first, then the rest in original order
|
||||
expect(next.fields.map(f => f.name)).toEqual(['b', 'a', 'c']);
|
||||
});
|
||||
|
||||
it('ignores unknown field names in newOrder', () => {
|
||||
const s = makeSchema(['a', 'b']);
|
||||
const next = reorderFields(s, ['unknown', 'a', 'b']);
|
||||
expect(next.fields.map(f => f.name)).toEqual(['a', 'b']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('replaceOutputFields', () => {
|
||||
it('replaces all fields', () => {
|
||||
const s = makeSchema(['a', 'b']);
|
||||
const next = replaceOutputFields(s, [makeField('x'), makeField('y')]);
|
||||
expect(next.fields.map(f => f.name)).toEqual(['x', 'y']);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Complexity + scoring ───────────────────────────────────────
|
||||
|
||||
describe('schemaComplexity', () => {
|
||||
it('returns 0 for empty schema', () => {
|
||||
expect(schemaComplexity(makeSchema([]))).toBe(0);
|
||||
});
|
||||
|
||||
it('scales with field count, constraint count, and description length', () => {
|
||||
const simple = schemaComplexity(makeSchema(['a']));
|
||||
const twoField = schemaComplexity(makeSchema(['a', 'b']));
|
||||
expect(twoField).toBeGreaterThan(simple);
|
||||
|
||||
const withConstraint = schemaComplexity(
|
||||
addConstraint(makeSchema(['a']), 'a', { kind: 'maxLength', value: 10 }),
|
||||
);
|
||||
expect(withConstraint).toBeGreaterThan(simple);
|
||||
});
|
||||
});
|
||||
|
||||
describe('aggregateSchemaScores', () => {
|
||||
it('returns zero aggregate on empty results', () => {
|
||||
const agg = aggregateSchemaScores([]);
|
||||
expect(agg.n).toBe(0);
|
||||
expect(agg.accuracy).toBe(0);
|
||||
expect(agg.parseRate).toBe(0);
|
||||
});
|
||||
|
||||
it('averages accuracy + computes parse rate', () => {
|
||||
const results = [
|
||||
{ input: 'a', expected: 'A', actual: 'A', score: makeJudgeScore(0.8), parsed: true },
|
||||
{ input: 'b', expected: 'B', actual: 'B', score: makeJudgeScore(0.6), parsed: true },
|
||||
{ input: 'c', expected: 'C', actual: 'X', score: makeJudgeScore(0.2), parsed: false },
|
||||
];
|
||||
const agg = aggregateSchemaScores(results);
|
||||
expect(agg.n).toBe(3);
|
||||
expect(agg.accuracy).toBeCloseTo((0.8 + 0.6 + 0.2) / 3, 5);
|
||||
expect(agg.parseRate).toBeCloseTo(2 / 3, 5);
|
||||
});
|
||||
|
||||
it('surfaces worst-3 example feedback', () => {
|
||||
const results = [
|
||||
{ input: 'a', expected: 'A', actual: 'A', score: makeJudgeScore(0.9, 'best'), parsed: true },
|
||||
{ input: 'b', expected: 'B', actual: 'B', score: makeJudgeScore(0.1, 'worst'), parsed: true },
|
||||
{ input: 'c', expected: 'C', actual: 'C', score: makeJudgeScore(0.5, 'middle'), parsed: true },
|
||||
];
|
||||
const agg = aggregateSchemaScores(results);
|
||||
expect(agg.weaknessFeedback).toContain('worst');
|
||||
expect(agg.weaknessFeedback).toContain('middle');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Pareto ─────────────────────────────────────────────────────
|
||||
|
||||
describe('paretoFrontSchema', () => {
|
||||
const makeScore = (accuracy: number, complexity: number): SchemaCandidateScore => ({
|
||||
accuracy, complexity, parseRate: 1, weaknessFeedback: [], n: 10,
|
||||
});
|
||||
|
||||
it('removes strictly dominated candidates', () => {
|
||||
const dominated = makeCandidate('d', makeSchema(['a']), makeScore(0.5, 5));
|
||||
const better = makeCandidate('b', makeSchema(['a']), makeScore(0.8, 3));
|
||||
const front = paretoFrontSchema([dominated, better]);
|
||||
expect(front.map(c => c.id)).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('keeps trade-off candidates (accurate vs simple)', () => {
|
||||
const accurate = makeCandidate('acc', makeSchema(['a', 'b']), makeScore(0.9, 10));
|
||||
const simple = makeCandidate('sim', makeSchema(['a']), makeScore(0.7, 2));
|
||||
const front = paretoFrontSchema([accurate, simple]);
|
||||
expect(front).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('ignores unscored candidates', () => {
|
||||
const scored = makeCandidate('s', makeSchema(['a']), makeScore(0.5, 1));
|
||||
const unscored = makeCandidate('u', makeSchema(['a']), null);
|
||||
const front = paretoFrontSchema([scored, unscored]);
|
||||
expect(front).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pickSchemaWinner', () => {
|
||||
const makeScore = (accuracy: number, complexity: number): SchemaCandidateScore => ({
|
||||
accuracy, complexity, parseRate: 1, weaknessFeedback: [], n: 10,
|
||||
});
|
||||
|
||||
it('picks highest accuracy', () => {
|
||||
const lo = makeCandidate('lo', makeSchema(['a']), makeScore(0.5, 1));
|
||||
const hi = makeCandidate('hi', makeSchema(['a']), makeScore(0.9, 10));
|
||||
expect(pickSchemaWinner([lo, hi]).id).toBe('hi');
|
||||
});
|
||||
|
||||
it('ties on accuracy → prefers lower complexity', () => {
|
||||
const complex = makeCandidate('cx', makeSchema(['a', 'b']), makeScore(0.8, 10));
|
||||
const simple = makeCandidate('sm', makeSchema(['a']), makeScore(0.8, 3));
|
||||
expect(pickSchemaWinner([complex, simple]).id).toBe('sm');
|
||||
});
|
||||
|
||||
it('throws on empty', () => {
|
||||
expect(() => pickSchemaWinner([])).toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Mutation generators ────────────────────────────────────────
|
||||
|
||||
describe('generateStructureMutations', () => {
|
||||
const rng = () => 0.5;
|
||||
|
||||
it('suggests adding reasoning if absent', () => {
|
||||
const s = makeSchema(['answer']);
|
||||
const muts = generateStructureMutations(s, 5, rng);
|
||||
expect(muts.some(m => m.description.includes('reasoning'))).toBe(true);
|
||||
});
|
||||
|
||||
it('does not re-suggest reasoning if already present', () => {
|
||||
const s = makeSchema(['reasoning', 'answer']);
|
||||
const muts = generateStructureMutations(s, 5, rng);
|
||||
const hasReasoning = muts.some(m => m.description.includes('reasoning field'));
|
||||
expect(hasReasoning).toBe(false);
|
||||
});
|
||||
|
||||
it('suggests drop when schema is large', () => {
|
||||
const s = makeSchema(['a', 'b', 'c', 'd', 'e']);
|
||||
const muts = generateStructureMutations(s, 5, rng);
|
||||
expect(muts.some(m => m.kind === 'remove_field')).toBe(true);
|
||||
});
|
||||
|
||||
it('returns at most n mutations', () => {
|
||||
const s = makeSchema(['answer']);
|
||||
const muts = generateStructureMutations(s, 1, rng);
|
||||
expect(muts.length).toBeLessThanOrEqual(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateOrderMutations', () => {
|
||||
const rng = () => 0.5;
|
||||
|
||||
it('returns empty for 1-field schemas', () => {
|
||||
expect(generateOrderMutations(makeSchema(['a']), 3, rng)).toEqual([]);
|
||||
});
|
||||
|
||||
it('moves a reasoning field to the front', () => {
|
||||
const s: Schema = {
|
||||
name: 't', version: 1,
|
||||
fields: [makeField('answer'), makeField('reasoning'), makeField('confidence')],
|
||||
};
|
||||
const muts = generateOrderMutations(s, 3, rng);
|
||||
const applied = muts[0].apply(s);
|
||||
expect(applied.fields[0].name).toBe('reasoning');
|
||||
});
|
||||
|
||||
it('moves a confidence field to the end', () => {
|
||||
const s: Schema = {
|
||||
name: 't', version: 1,
|
||||
fields: [makeField('confidence'), makeField('reasoning'), makeField('answer')],
|
||||
};
|
||||
const muts = generateOrderMutations(s, 3, rng);
|
||||
const last = muts[0].apply(s);
|
||||
// One of the generated mutations should put 'confidence' at the end
|
||||
const someMovesConfidenceToEnd = muts.some(m => {
|
||||
const applied = m.apply(s);
|
||||
return applied.fields[applied.fields.length - 1].name === 'confidence';
|
||||
});
|
||||
expect(someMovesConfidenceToEnd).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateRefinementMutations', () => {
|
||||
it('adds maxLength when feedback mentions verbosity', async () => {
|
||||
const s = makeSchema(['reasoning', 'answer']);
|
||||
const muts = await generateRefinementMutations(
|
||||
s, ['too verbose, output wordy'], 5,
|
||||
);
|
||||
expect(muts.some(m => m.kind === 'add_constraint')).toBe(true);
|
||||
});
|
||||
|
||||
it('adds minLength when feedback mentions incompleteness', async () => {
|
||||
const s = makeSchema(['answer']);
|
||||
const muts = await generateRefinementMutations(
|
||||
s, ['response is too brief, missing detail'], 5,
|
||||
);
|
||||
expect(muts.some(m =>
|
||||
m.description.includes('minLength'),
|
||||
)).toBe(true);
|
||||
});
|
||||
|
||||
it('edits field descriptions when feedback mentions format issues', async () => {
|
||||
const s = makeSchema(['answer']);
|
||||
const muts = await generateRefinementMutations(
|
||||
s, ['wrong format, could not parse'], 5,
|
||||
);
|
||||
expect(muts.some(m => m.kind === 'edit_field_desc')).toBe(true);
|
||||
});
|
||||
|
||||
it('falls back to a single description rewrite when no heuristic matches', async () => {
|
||||
const s = makeSchema(['answer']);
|
||||
const muts = await generateRefinementMutations(s, ['something unrelated'], 5);
|
||||
expect(muts.length).toBeGreaterThan(0);
|
||||
expect(muts[0].kind).toBe('edit_field_desc');
|
||||
});
|
||||
|
||||
it('uses LLM-provided editor when supplied', async () => {
|
||||
const s = makeSchema(['answer']);
|
||||
const muts = await generateRefinementMutations(
|
||||
s, ['wrong format'], 5,
|
||||
async ({ field }) => `LLM-REWRITE of ${field.name}`,
|
||||
);
|
||||
const edit = muts.find(m => m.kind === 'edit_field_desc');
|
||||
const applied = edit!.apply(s);
|
||||
expect(applied.fields[0].description).toBe('LLM-REWRITE of answer');
|
||||
});
|
||||
});
|
||||
|
||||
// ── pickSample ─────────────────────────────────────────────────
|
||||
|
||||
describe('pickSample', () => {
|
||||
it('returns up to k items', () => {
|
||||
expect(pickSample(makeExamples(5), 3, () => 0.5)).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('returns empty on k=0', () => {
|
||||
expect(pickSample(makeExamples(5), 0, () => 0.5)).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
// ── EvolveSchema.run end-to-end ───────────────────────────────
|
||||
|
||||
describe('EvolveSchema.run', () => {
|
||||
it('runs through all phases and returns a winner', async () => {
|
||||
const baseline = makeSchema(['answer']);
|
||||
const result = await new EvolveSchema().run({
|
||||
baseline,
|
||||
examples: makeExamples(10),
|
||||
execute: makeFakeExecutor(),
|
||||
judge: makeJudge('prefers-more'),
|
||||
populationSize: 3,
|
||||
generations: 1,
|
||||
evalSize: 5,
|
||||
anchorEvalSize: 10,
|
||||
});
|
||||
expect(result.winner).toBeDefined();
|
||||
expect(result.winner.score).not.toBeNull();
|
||||
expect(result.history.length).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it('winner accuracy >= baseline accuracy when judge prefers richer schemas', async () => {
|
||||
const baseline = makeSchema(['answer']);
|
||||
const result = await new EvolveSchema().run({
|
||||
baseline,
|
||||
examples: makeExamples(10),
|
||||
execute: makeFakeExecutor(),
|
||||
judge: makeJudge('prefers-more'),
|
||||
populationSize: 3,
|
||||
generations: 2,
|
||||
evalSize: 5,
|
||||
anchorEvalSize: 10,
|
||||
});
|
||||
expect(result.winner.score!.accuracy).toBeGreaterThanOrEqual(
|
||||
result.history[0].score!.accuracy,
|
||||
);
|
||||
});
|
||||
|
||||
it('emits progress events for each phase', async () => {
|
||||
const phases: string[] = [];
|
||||
await new EvolveSchema().run({
|
||||
baseline: makeSchema(['answer']),
|
||||
examples: makeExamples(5),
|
||||
execute: makeFakeExecutor(),
|
||||
judge: makeJudge(),
|
||||
populationSize: 2,
|
||||
generations: 1,
|
||||
evalSize: 3,
|
||||
anchorEvalSize: 3,
|
||||
onProgress: (e) => phases.push(e.phase),
|
||||
});
|
||||
expect(phases).toContain('start');
|
||||
expect(phases).toContain('structure');
|
||||
expect(phases).toContain('order');
|
||||
expect(phases).toContain('refinement');
|
||||
expect(phases).toContain('anchor');
|
||||
expect(phases).toContain('done');
|
||||
});
|
||||
|
||||
it('is deterministic with same seed', async () => {
|
||||
const cfg = () => ({
|
||||
baseline: makeSchema(['answer']),
|
||||
examples: makeExamples(10),
|
||||
execute: makeFakeExecutor(),
|
||||
judge: makeJudge('prefers-more'),
|
||||
populationSize: 3,
|
||||
generations: 1,
|
||||
evalSize: 5,
|
||||
anchorEvalSize: 8,
|
||||
seed: 7,
|
||||
});
|
||||
const a = await new EvolveSchema().run(cfg());
|
||||
const b = await new EvolveSchema().run(cfg());
|
||||
expect(a.winner.schema.fields.map(f => f.name)).toEqual(
|
||||
b.winner.schema.fields.map(f => f.name),
|
||||
);
|
||||
});
|
||||
|
||||
it('respects abort signal', async () => {
|
||||
const ctrl = new AbortController();
|
||||
ctrl.abort();
|
||||
const result = await new EvolveSchema().run({
|
||||
baseline: makeSchema(['answer']),
|
||||
examples: makeExamples(10),
|
||||
execute: makeFakeExecutor(),
|
||||
judge: makeJudge(),
|
||||
populationSize: 2,
|
||||
generations: 2,
|
||||
evalSize: 3,
|
||||
anchorEvalSize: 3,
|
||||
signal: ctrl.signal,
|
||||
});
|
||||
expect(result.winner).toBeDefined();
|
||||
});
|
||||
|
||||
it('records all candidates in history with generation numbers', async () => {
|
||||
const result = await new EvolveSchema().run({
|
||||
baseline: makeSchema(['answer']),
|
||||
examples: makeExamples(5),
|
||||
execute: makeFakeExecutor(),
|
||||
judge: makeJudge(),
|
||||
populationSize: 2,
|
||||
generations: 2,
|
||||
mutationMix: { structure: 2, order: 0, refinement: 0 },
|
||||
evalSize: 3,
|
||||
anchorEvalSize: 3,
|
||||
});
|
||||
const gen0 = result.history.filter(c => c.generation === 0);
|
||||
const gen1 = result.history.filter(c => c.generation === 1);
|
||||
expect(gen0.length).toBe(1);
|
||||
expect(gen1.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user