Files
waggle-os/packages/agent/tests/judge.test.ts
Oleg Maslov 0c3e2ead3b
Some checks failed
Installer Smoke / installer-smoke (push) Has been cancelled
moving
2026-09-02 10:10:29 +02:00

258 lines
9.1 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
LLMJudge,
DEFAULT_WEIGHTS,
DEFAULT_RUBRIC,
buildPrompt as buildJudgePrompt,
parseJudgeResponse,
computeLengthPenalty,
type JudgeLLMCall,
type JudgeInput,
} from '../src/judge.js';
// ── Pure helpers ───────────────────────────────────────────────
describe('buildJudgePrompt', () => {
it('includes instruction, expected, actual in order', () => {
const prompt = buildJudgePrompt('RUBRIC TEXT', {
input: 'the question',
expected: 'the answer',
actual: 'the candidate',
});
expect(prompt).toContain('RUBRIC TEXT');
expect(prompt).toContain('INSTRUCTION:\nthe question');
expect(prompt).toContain('EXPECTED:\nthe answer');
expect(prompt).toContain('ACTUAL:\nthe candidate');
expect(prompt.indexOf('INSTRUCTION')).toBeLessThan(prompt.indexOf('EXPECTED'));
expect(prompt.indexOf('EXPECTED')).toBeLessThan(prompt.indexOf('ACTUAL'));
});
it('includes optional context line when provided', () => {
const prompt = buildJudgePrompt('R', {
input: 'x', expected: 'y', actual: 'z', context: 'persona: coder',
});
expect(prompt).toContain('CONTEXT: persona: coder');
});
});
describe('parseJudgeResponse', () => {
it('parses clean JSON', () => {
const raw = '{"correctness": 8, "procedure": 7, "conciseness": 9, "feedback": "Nice"}';
const parsed = parseJudgeResponse(raw);
expect(parsed).toEqual({ correctness: 8, procedure: 7, conciseness: 9, feedback: 'Nice' });
});
it('strips markdown code fences', () => {
const raw = '```json\n{"correctness":5,"procedure":5,"conciseness":5,"feedback":"ok"}\n```';
const parsed = parseJudgeResponse(raw);
expect(parsed?.correctness).toBe(5);
});
it('handles extra prose before and after the JSON', () => {
const raw = 'Sure! Here is the evaluation:\n{"correctness":9,"procedure":8,"conciseness":10,"feedback":"Tight"}\nLet me know if you need more.';
const parsed = parseJudgeResponse(raw);
expect(parsed?.correctness).toBe(9);
expect(parsed?.feedback).toBe('Tight');
});
it('prefers the outer JSON when nested objects are present in strings', () => {
const raw = '{"correctness":6,"procedure":6,"conciseness":6,"feedback":"Has {nested} braces in text"}';
const parsed = parseJudgeResponse(raw);
expect(parsed?.feedback).toBe('Has {nested} braces in text');
});
it('returns null for empty input', () => {
expect(parseJudgeResponse('')).toBeNull();
});
it('returns null when required numeric fields are missing', () => {
expect(parseJudgeResponse('{"feedback":"just prose"}')).toBeNull();
});
it('returns null for non-JSON text', () => {
expect(parseJudgeResponse('The score is 10/10 — perfect!')).toBeNull();
});
it('returns null for malformed JSON', () => {
expect(parseJudgeResponse('{"correctness": 5, "procedure":')).toBeNull();
});
it('defaults feedback to empty string when missing', () => {
const parsed = parseJudgeResponse('{"correctness":1,"procedure":2,"conciseness":3}');
expect(parsed?.feedback).toBe('');
});
});
describe('computeLengthPenalty', () => {
it('returns 1.0 when within tolerance', () => {
expect(computeLengthPenalty(1500, 2000, 0.5, 0.5)).toBe(1);
expect(computeLengthPenalty(3000, 2000, 0.5, 0.5)).toBe(1); // = target * 1.5
});
it('returns floor when far beyond limit', () => {
expect(computeLengthPenalty(6000, 2000, 0.5, 0.5)).toBe(0.5); // = 3 * target
expect(computeLengthPenalty(100000, 2000, 0.5, 0.5)).toBe(0.5);
});
it('interpolates linearly between limit and 3*target', () => {
// limit = 3000, farLimit = 6000 — midpoint is 4500 → penalty = 0.75
expect(computeLengthPenalty(4500, 2000, 0.5, 0.5)).toBeCloseTo(0.75, 5);
});
it('returns 1 for empty actual', () => {
expect(computeLengthPenalty(0, 2000, 0.5, 0.5)).toBe(1);
});
it('returns 1 if target is non-positive', () => {
expect(computeLengthPenalty(100, 0, 0.5, 0.5)).toBe(1);
});
});
// ── LLMJudge (with a stub LLM) ─────────────────────────────────
function makeLLM(responses: string[] | ((prompt: string) => string)): JudgeLLMCall {
if (typeof responses === 'function') {
return async (prompt: string) => responses(prompt);
}
let i = 0;
return async () => {
const r = responses[Math.min(i, responses.length - 1)];
i++;
return r;
};
}
describe('LLMJudge', () => {
const example: JudgeInput = {
input: 'What is 2 + 2?',
expected: '4',
actual: 'The answer is 4.',
};
it('scores a clean response with weighted sum', async () => {
const llm = makeLLM([
'{"correctness":10,"procedure":10,"conciseness":10,"feedback":"Perfect"}',
]);
const judge = new LLMJudge(llm);
const score = await judge.score(example);
expect(score.parsed).toBe(true);
expect(score.correctness).toBe(1);
expect(score.procedureFollowing).toBe(1);
expect(score.conciseness).toBe(1);
expect(score.weighted).toBeCloseTo(1, 5);
expect(score.overall).toBeCloseTo(1, 5);
expect(score.feedback).toBe('Perfect');
expect(score.lengthPenalty).toBe(1);
});
it('applies weights correctly', async () => {
const llm = makeLLM([
'{"correctness":10,"procedure":0,"conciseness":0,"feedback":""}',
]);
const judge = new LLMJudge(llm);
const score = await judge.score(example);
// correctness weight is 0.5 → overall = 1 * 0.5 + 0 + 0
expect(score.weighted).toBeCloseTo(DEFAULT_WEIGHTS.correctness, 5);
});
it('clamps scores outside 0-10 into 0..1', async () => {
const llm = makeLLM([
'{"correctness":15,"procedure":-3,"conciseness":5,"feedback":""}',
]);
const judge = new LLMJudge(llm);
const score = await judge.score(example);
expect(score.correctness).toBe(1);
expect(score.procedureFollowing).toBe(0);
expect(score.conciseness).toBe(0.5);
});
it('applies length penalty to verbose responses', async () => {
const llm = makeLLM([
'{"correctness":10,"procedure":10,"conciseness":10,"feedback":"verbose"}',
]);
const judge = new LLMJudge(llm, { lengthTarget: 20, lengthTolerance: 0.5 });
const verbose: JudgeInput = {
input: 'x', expected: 'y', actual: 'z'.repeat(60), // far beyond 3*target=60
};
const score = await judge.score(verbose);
expect(score.lengthPenalty).toBeLessThan(1);
expect(score.overall).toBeLessThan(score.weighted);
});
it('returns errorScore when LLM throws', async () => {
const llm: JudgeLLMCall = async () => {
throw new Error('network down');
};
const judge = new LLMJudge(llm);
const score = await judge.score(example);
expect(score.parsed).toBe(false);
expect(score.overall).toBe(0);
expect(score.feedback).toContain('network down');
});
it('returns errorScore when response cannot be parsed', async () => {
const llm = makeLLM(['totally unparseable garbage']);
const judge = new LLMJudge(llm);
const score = await judge.score(example);
expect(score.parsed).toBe(false);
expect(score.overall).toBe(0);
expect(score.feedback).toContain('could not be parsed');
});
it('scoreBatch processes inputs in order', async () => {
const responses = [
'{"correctness":10,"procedure":10,"conciseness":10,"feedback":"A"}',
'{"correctness":5,"procedure":5,"conciseness":5,"feedback":"B"}',
'{"correctness":0,"procedure":0,"conciseness":0,"feedback":"C"}',
];
const llm = makeLLM(responses);
const judge = new LLMJudge(llm);
const scores = await judge.scoreBatch([example, example, example]);
expect(scores.map(s => s.feedback)).toEqual(['A', 'B', 'C']);
expect(scores[0].overall).toBeGreaterThan(scores[1].overall);
expect(scores[1].overall).toBeGreaterThan(scores[2].overall);
});
it('honors custom weights', async () => {
const llm = makeLLM([
'{"correctness":0,"procedure":10,"conciseness":0,"feedback":""}',
]);
const judge = new LLMJudge(llm, {
weights: { correctness: 0.1, procedure: 0.8, conciseness: 0.1 },
});
const score = await judge.score(example);
expect(score.weighted).toBeCloseTo(0.8, 5);
});
it('rejects weights that do not sum to 1', () => {
expect(() => new LLMJudge(makeLLM(['']), {
weights: { correctness: 0.5, procedure: 0.3, conciseness: 0.1 },
})).toThrow(/sum to 1/);
});
it('rejects negative weights', () => {
expect(() => new LLMJudge(makeLLM(['']), {
weights: { correctness: -0.1, procedure: 0.6, conciseness: 0.5 },
})).toThrow(/non-negative/);
});
it('uses rubric override when provided', async () => {
let capturedPrompt = '';
const llm: JudgeLLMCall = async (prompt) => {
capturedPrompt = prompt;
return '{"correctness":5,"procedure":5,"conciseness":5,"feedback":""}';
};
const judge = new LLMJudge(llm, { rubricOverride: 'CUSTOM_RUBRIC_MARKER' });
await judge.score(example);
expect(capturedPrompt).toContain('CUSTOM_RUBRIC_MARKER');
expect(capturedPrompt).not.toContain(DEFAULT_RUBRIC.slice(0, 40));
});
});